From 46bf5a2bd233771e868ebb5f6040353bac132a66 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 16 May 2026 14:15:04 +0300 Subject: [PATCH] test(engine): split profiler coverage by module --- scripts/steiger-rules.ts | 5 + tests/engine/profiler/basic.test.ts | 185 ------------------ tests/engine/profiler/capture-stack.test.ts | 23 +++ .../engine/profiler/draw-call-counter.test.ts | 41 ++++ tests/engine/profiler/frame-stats.test.ts | 38 ++++ tests/engine/profiler/gpu-timer.test.ts | 16 ++ tests/engine/profiler/phase-timer.test.ts | 25 +++ tests/engine/profiler/speedscope.test.ts | 55 ++++++ 8 files changed, 203 insertions(+), 185 deletions(-) delete mode 100644 tests/engine/profiler/basic.test.ts create mode 100644 tests/engine/profiler/capture-stack.test.ts create mode 100644 tests/engine/profiler/draw-call-counter.test.ts create mode 100644 tests/engine/profiler/frame-stats.test.ts create mode 100644 tests/engine/profiler/gpu-timer.test.ts create mode 100644 tests/engine/profiler/phase-timer.test.ts create mode 100644 tests/engine/profiler/speedscope.test.ts diff --git a/scripts/steiger-rules.ts b/scripts/steiger-rules.ts index edda8e628..1a4a83562 100644 --- a/scripts/steiger-rules.ts +++ b/scripts/steiger-rules.ts @@ -277,6 +277,11 @@ const ENGINE_TEST_DOMAIN_REDIRECTS: Array<{ from: 'tests/engine/geometry/basic', to: 'tests/engine/geometry/angles.test.ts, tests/engine/geometry/rotation.test.ts, tests/engine/geometry/bounds.test.ts, or tests/engine/geometry/visual-bounds.test.ts', source: 'the geometry module under test' + }, + { + from: 'tests/engine/profiler/basic', + to: 'tests/engine/profiler/frame-stats.test.ts, draw-call-counter.test.ts, gpu-timer.test.ts, phase-timer.test.ts, capture-stack.test.ts, or speedscope.test.ts', + source: 'the profiler module under test' } ] diff --git a/tests/engine/profiler/basic.test.ts b/tests/engine/profiler/basic.test.ts deleted file mode 100644 index ba12e4d65..000000000 --- a/tests/engine/profiler/basic.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it, expect } from 'bun:test' - -import { - FrameStats, - DrawCallCounter, - PhaseTimer, - GPUTimer, - CaptureStack, - toSpeedscopeJSON -} from '@open-pencil/core' - -describe('FrameStats', () => { - it('records frames and computes rolling averages', () => { - const stats = new FrameStats() - - for (let i = 0; i < 10; i++) { - stats.recordFrame(5) - } - - expect(stats.avgCpuTime).toBe(5) - expect(stats.smoothedFps).toBeGreaterThan(0) - expect(stats.getFrameTimeHistory()).toBeInstanceOf(Float64Array) - expect(stats.getBufferCount()).toBe(10) - }) - - it('tracks external fields', () => { - const stats = new FrameStats() - stats.totalNodes = 100 - stats.culledNodes = 20 - stats.drawCalls = 50 - stats.scenePictureCacheHit = true - - expect(stats.totalNodes).toBe(100) - expect(stats.culledNodes).toBe(20) - expect(stats.drawCalls).toBe(50) - expect(stats.scenePictureCacheHit).toBe(true) - }) - - it('handles GPU time as NaN initially', () => { - const stats = new FrameStats() - stats.recordFrame(1) - expect(stats.gpuTime).toBe(0) - }) -}) - -describe('DrawCallCounter', () => { - it('handles null GL context', () => { - const counter = new DrawCallCounter(null) - expect(counter.count).toBe(0) - expect(counter.reset()).toBe(0) - counter.destroy() - }) - - it('wraps draw calls only while enabled', () => { - let rawDrawCalls = 0 - const gl = { - drawArrays: () => rawDrawCalls++, - drawElements: () => rawDrawCalls++, - drawArraysInstanced: () => rawDrawCalls++, - drawElementsInstanced: () => rawDrawCalls++ - } as WebGL2RenderingContext - const originalDrawArrays = gl.drawArrays - - const counter = new DrawCallCounter(gl) - gl.drawArrays(0, 0, 0) - expect(counter.count).toBe(0) - expect(rawDrawCalls).toBe(1) - - counter.enable() - expect(gl.drawArrays).not.toBe(originalDrawArrays) - gl.drawArrays(0, 0, 0) - expect(counter.count).toBe(1) - expect(rawDrawCalls).toBe(2) - - counter.disable() - expect(gl.drawArrays).toBe(originalDrawArrays) - gl.drawArrays(0, 0, 0) - expect(counter.count).toBe(1) - expect(rawDrawCalls).toBe(3) - }) -}) - -describe('GPUTimer', () => { - it('handles null GL context', () => { - const timer = new GPUTimer(null) - expect(timer.available).toBe(false) - expect(timer.lastGpuTimeMs).toBeNaN() - timer.beginFrame() - timer.endFrame() - expect(timer.pollResults()).toBeNull() - timer.destroy() - }) -}) - -describe('PhaseTimer', () => { - it('is disabled by default', () => { - const timer = new PhaseTimer() - expect(timer.enabled).toBe(false) - }) - - it('records measures when enabled', () => { - const timer = new PhaseTimer() - timer.enabled = true - timer.beginPhase('test') - timer.endPhase('test') - timer.clearPhases() - }) - - it('is a no-op when disabled', () => { - const timer = new PhaseTimer() - timer.beginPhase('test') - timer.endPhase('test') - }) -}) - -describe('CaptureStack', () => { - it('builds a tree of node profiles', () => { - const stack = new CaptureStack() - stack.reset(performance.now()) - - stack.begin('node-1', 'Frame 1', 'FRAME', false) - stack.begin('node-2', 'Rect', 'RECTANGLE', false) - stack.end(2) - stack.end(3) - - const roots = stack.getRootProfiles() - expect(roots).toHaveLength(1) - expect(roots[0].nodeId).toBe('node-1') - expect(roots[0].children).toHaveLength(1) - expect(roots[0].children[0].nodeId).toBe('node-2') - expect(roots[0].children[0].drawCalls).toBe(2) - }) -}) - -describe('toSpeedscopeJSON', () => { - it('produces valid speedscope JSON', () => { - const capture = { - timestamp: 0, - totalTimeMs: 10, - cpuTimeMs: 8, - gpuTimeMs: 6, - totalNodes: 5, - culledNodes: 1, - drawCalls: 10, - scenePictureCacheHit: false, - rootProfiles: [ - { - nodeId: 'n1', - name: 'Frame', - type: 'FRAME', - depth: 0, - startTime: 0, - endTime: 10, - selfTime: 5, - drawCalls: 3, - culled: false, - children: [ - { - nodeId: 'n2', - name: 'Rect', - type: 'RECTANGLE', - depth: 1, - startTime: 2, - endTime: 7, - selfTime: 5, - drawCalls: 2, - culled: false, - children: [] - } - ] - } - ] - } - - const json = toSpeedscopeJSON(capture) - const parsed = JSON.parse(json) - - expect(parsed.$schema).toBe('https://www.speedscope.app/file-format-schema.json') - expect(parsed.profiles).toHaveLength(1) - expect(parsed.profiles[0].type).toBe('evented') - expect(parsed.profiles[0].unit).toBe('milliseconds') - expect(parsed.shared.frames).toHaveLength(2) - expect(parsed.profiles[0].events).toHaveLength(4) - }) -}) diff --git a/tests/engine/profiler/capture-stack.test.ts b/tests/engine/profiler/capture-stack.test.ts new file mode 100644 index 000000000..5cf4f7eee --- /dev/null +++ b/tests/engine/profiler/capture-stack.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'bun:test' + +import { CaptureStack } from '@open-pencil/core' + +describe('CaptureStack', () => { + it('builds a tree of node profiles', () => { + const stack = new CaptureStack() + stack.reset(performance.now()) + + stack.begin('node-1', 'Frame 1', 'FRAME', false) + stack.begin('node-2', 'Rect', 'RECTANGLE', false) + stack.end(2) + stack.end(3) + + const roots = stack.getRootProfiles() + expect(roots).toHaveLength(1) + expect(roots[0].nodeId).toBe('node-1') + expect(roots[0].children).toHaveLength(1) + expect(roots[0].children[0].nodeId).toBe('node-2') + expect(roots[0].children[0].drawCalls).toBe(2) + }) +}) + diff --git a/tests/engine/profiler/draw-call-counter.test.ts b/tests/engine/profiler/draw-call-counter.test.ts new file mode 100644 index 000000000..f32e635cd --- /dev/null +++ b/tests/engine/profiler/draw-call-counter.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'bun:test' + +import { DrawCallCounter } from '@open-pencil/core' + +describe('DrawCallCounter', () => { + it('handles null GL context', () => { + const counter = new DrawCallCounter(null) + expect(counter.count).toBe(0) + expect(counter.reset()).toBe(0) + counter.destroy() + }) + + it('wraps draw calls only while enabled', () => { + let rawDrawCalls = 0 + const gl = { + drawArrays: () => rawDrawCalls++, + drawElements: () => rawDrawCalls++, + drawArraysInstanced: () => rawDrawCalls++, + drawElementsInstanced: () => rawDrawCalls++ + } as WebGL2RenderingContext + const originalDrawArrays = gl.drawArrays + + const counter = new DrawCallCounter(gl) + gl.drawArrays(0, 0, 0) + expect(counter.count).toBe(0) + expect(rawDrawCalls).toBe(1) + + counter.enable() + expect(gl.drawArrays).not.toBe(originalDrawArrays) + gl.drawArrays(0, 0, 0) + expect(counter.count).toBe(1) + expect(rawDrawCalls).toBe(2) + + counter.disable() + expect(gl.drawArrays).toBe(originalDrawArrays) + gl.drawArrays(0, 0, 0) + expect(counter.count).toBe(1) + expect(rawDrawCalls).toBe(3) + }) +}) + diff --git a/tests/engine/profiler/frame-stats.test.ts b/tests/engine/profiler/frame-stats.test.ts new file mode 100644 index 000000000..c91536317 --- /dev/null +++ b/tests/engine/profiler/frame-stats.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'bun:test' + +import { FrameStats } from '@open-pencil/core' + +describe('FrameStats', () => { + it('records frames and computes rolling averages', () => { + const stats = new FrameStats() + + for (let i = 0; i < 10; i++) { + stats.recordFrame(5) + } + + expect(stats.avgCpuTime).toBe(5) + expect(stats.smoothedFps).toBeGreaterThan(0) + expect(stats.getFrameTimeHistory()).toBeInstanceOf(Float64Array) + expect(stats.getBufferCount()).toBe(10) + }) + + it('tracks external fields', () => { + const stats = new FrameStats() + stats.totalNodes = 100 + stats.culledNodes = 20 + stats.drawCalls = 50 + stats.scenePictureCacheHit = true + + expect(stats.totalNodes).toBe(100) + expect(stats.culledNodes).toBe(20) + expect(stats.drawCalls).toBe(50) + expect(stats.scenePictureCacheHit).toBe(true) + }) + + it('handles GPU time as NaN initially', () => { + const stats = new FrameStats() + stats.recordFrame(1) + expect(stats.gpuTime).toBe(0) + }) +}) + diff --git a/tests/engine/profiler/gpu-timer.test.ts b/tests/engine/profiler/gpu-timer.test.ts new file mode 100644 index 000000000..3b0351e68 --- /dev/null +++ b/tests/engine/profiler/gpu-timer.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'bun:test' + +import { GPUTimer } from '@open-pencil/core' + +describe('GPUTimer', () => { + it('handles null GL context', () => { + const timer = new GPUTimer(null) + expect(timer.available).toBe(false) + expect(timer.lastGpuTimeMs).toBeNaN() + timer.beginFrame() + timer.endFrame() + expect(timer.pollResults()).toBeNull() + timer.destroy() + }) +}) + diff --git a/tests/engine/profiler/phase-timer.test.ts b/tests/engine/profiler/phase-timer.test.ts new file mode 100644 index 000000000..3bfaeaa66 --- /dev/null +++ b/tests/engine/profiler/phase-timer.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'bun:test' + +import { PhaseTimer } from '@open-pencil/core' + +describe('PhaseTimer', () => { + it('is disabled by default', () => { + const timer = new PhaseTimer() + expect(timer.enabled).toBe(false) + }) + + it('records measures when enabled', () => { + const timer = new PhaseTimer() + timer.enabled = true + timer.beginPhase('test') + timer.endPhase('test') + timer.clearPhases() + }) + + it('is a no-op when disabled', () => { + const timer = new PhaseTimer() + timer.beginPhase('test') + timer.endPhase('test') + }) +}) + diff --git a/tests/engine/profiler/speedscope.test.ts b/tests/engine/profiler/speedscope.test.ts new file mode 100644 index 000000000..a364dfe71 --- /dev/null +++ b/tests/engine/profiler/speedscope.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'bun:test' + +import { toSpeedscopeJSON } from '@open-pencil/core' + +describe('toSpeedscopeJSON', () => { + it('produces valid speedscope JSON', () => { + const capture = { + timestamp: 0, + totalTimeMs: 10, + cpuTimeMs: 8, + gpuTimeMs: 6, + totalNodes: 5, + culledNodes: 1, + drawCalls: 10, + scenePictureCacheHit: false, + rootProfiles: [ + { + nodeId: 'n1', + name: 'Frame', + type: 'FRAME', + depth: 0, + startTime: 0, + endTime: 10, + selfTime: 5, + drawCalls: 3, + culled: false, + children: [ + { + nodeId: 'n2', + name: 'Rect', + type: 'RECTANGLE', + depth: 1, + startTime: 2, + endTime: 7, + selfTime: 5, + drawCalls: 2, + culled: false, + children: [] + } + ] + } + ] + } + + const json = toSpeedscopeJSON(capture) + const parsed = JSON.parse(json) + + expect(parsed.$schema).toBe('https://www.speedscope.app/file-format-schema.json') + expect(parsed.profiles).toHaveLength(1) + expect(parsed.profiles[0].type).toBe('evented') + expect(parsed.profiles[0].unit).toBe('milliseconds') + expect(parsed.shared.frames).toHaveLength(2) + expect(parsed.profiles[0].events).toHaveLength(4) + }) +})