Files
feuerwerk/tests/ui/render.test.ts
T
adminandClaude Opus 5.5 2f13f9a0b9
feuerwerk / gate (push) Successful in 4m52s
feuerwerk / suite (push) Successful in 19m5s
Keep a recording behind another tab from dropping its frames
In CI run 699 check 68k (real time behind a tab) got 21 of 120 frames.
The beat ticked 100 times, but 83 frames were dropped as busy. Late in
a long browser session the GPU behind a hidden tab took up to 1.5 s to
finish a read back, so all three read-back buffers were still on their
way when the next frame was due. Locally on melektaus-atx this showed
only in the full run of modules 01 to 68, and not in every one.

- The render loop no longer draws the screen while the page is hidden.
  Nobody sees it there, and the drawing was competing with the read
  backs for the slow GPU.
- A recording of the editor copies the canvas, so it turns the drawing
  back on with `drawWhileHidden` while it runs.
- `PictureReader.collect` reads the oldest buffer at once when the page
  is hidden and no buffer is free, instead of dropping the frame. The
  main thread waits for the GPU there, which nobody sees either.
- More buffers are no fix: with 30 of them the frames piled up at the
  encoder instead (45 frames, 56 dropped).

Tests:
- render.test.ts: 18/18, the new test is red without the hidden check
- Full run of modules 01 to 68 on melektaus-atx with a counting probe:
  read at once only 18 frames, 63 dropped; both parts 106 frames,
  2 dropped
- e2e 67 and 68 on melektaus-atx without probe: 68k green, 97 frames,
  0 dropped. Red there only "a clip that starts later", the known
  timing wobble from task 697
- Gate on melektaus-atx: CHECK EXIT 0, 313 files, 4841 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:02:08 +02:00

308 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Application } from 'pixi.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
afterFrames,
cancelFrameWork,
drawWhileHidden,
initRenderLoop,
isRendering,
onFrame,
renderCount,
requestRender,
resetRenderLoop,
stagePixel,
stagePixels,
} from '../../src/ui/render';
/** A stand-in for the Pixi application: counts draws and remembers what was put on the ticker. */
function fakeApp() {
const added: { fn: () => void; priority?: number }[] = [];
const removed: { fn: unknown; context: unknown }[] = [];
const app = {
drawn: 0,
render(): void {
app.drawn += 1;
},
ticker: {
add(fn: () => void, _context?: unknown, priority?: number) {
added.push({ fn, priority });
return this;
},
remove(fn: unknown, context?: unknown) {
removed.push({ fn, context });
return this;
},
},
};
return { app, added, removed, tick: () => added.forEach((entry) => entry.fn()) };
}
describe('render loop', () => {
let now = 0;
beforeEach(() => {
now = 1000;
vi.spyOn(performance, 'now').mockImplementation(() => now);
resetRenderLoop();
});
afterEach(() => vi.restoreAllMocks());
it('is asleep until something asks for a frame', () => {
expect(isRendering()).toBe(false);
});
it('stays awake for a window after the request, not for a single frame', () => {
requestRender();
expect(isRendering()).toBe(true);
now += 199;
expect(isRendering()).toBe(true);
now += 2;
expect(isRendering()).toBe(false);
});
it('a later request extends the window rather than queueing a second one', () => {
requestRender();
now += 150;
requestRender();
now += 150;
expect(isRendering()).toBe(true);
});
it('takes the renderer off the ticker and drives it itself', () => {
const { app, removed, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
expect(removed).toEqual([{ fn: app.render, context: app }]);
// `initRenderLoop` opens the first window, so the first frames are drawn.
tick();
tick();
expect(app.drawn).toBe(2);
expect(renderCount()).toBe(2);
});
it('draws nothing while nothing changed, and again after a request', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
for (let i = 0; i < 30; i++) tick();
expect(app.drawn).toBe(0);
expect(renderCount()).toBe(0);
requestRender();
tick();
expect(app.drawn).toBe(1);
// And it falls asleep again by itself.
now += 500;
tick();
expect(app.drawn).toBe(1);
});
it('afterFrames resolves only once that many frames were really drawn', async () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
let done = false;
const waited = afterFrames(2).then(() => (done = true));
tick();
await Promise.resolve();
expect(done).toBe(false);
tick();
await waited;
expect(done).toBe(true);
expect(app.drawn).toBe(2);
});
it('afterFrames wakes the loop itself, so an idle editor still answers', async () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
for (let i = 0; i < 5; i++) tick();
expect(app.drawn).toBe(0);
const waited = afterFrames(1);
tick();
await waited;
expect(app.drawn).toBe(1);
});
it('runs work put off to a frame once, however often it was asked for', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
let ran = 0;
for (let i = 0; i < 20; i++) onFrame('drag', () => (ran += 1));
expect(ran).toBe(0);
tick();
expect(ran).toBe(1);
// And it is gone afterwards: the next frame has nothing left to do.
tick();
expect(ran).toBe(1);
});
it('keeps the work of two keys apart, and the last one wins per key', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
const ran: string[] = [];
onFrame('a', () => ran.push('a1'));
onFrame('a', () => ran.push('a2'));
onFrame('b', () => ran.push('b'));
tick();
expect(ran).toEqual(['a2', 'b']);
});
it('runs the work before the frame it is meant to be in', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
let drawnWhenRun = -1;
onFrame('drag', () => (drawnWhenRun = app.drawn));
tick();
expect(drawnWhenRun).toBe(0);
expect(app.drawn).toBe(1);
});
it('wakes the loop itself, so work asked for by an idle editor is not lost', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
tick();
expect(app.drawn).toBe(0);
let ran = 0;
onFrame('drag', () => (ran += 1));
tick();
expect(ran).toBe(1);
});
it('cancelled work never runs – a gesture that ended writes nothing more', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
now += 500;
let ran = 0;
onFrame('drag', () => (ran += 1));
onFrame('other', () => (ran += 10));
cancelFrameWork('drag');
tick();
expect(ran).toBe(10);
});
it('draws nothing behind another tab, unless a recording of the editor needs the canvas', () => {
const { app, tick } = fakeApp();
initRenderLoop(app as unknown as Application);
const page = { hidden: true };
vi.stubGlobal('document', page);
try {
let ran = 0;
onFrame('drag', () => (ran += 1));
tick();
expect(ran).toBe(1);
expect(app.drawn).toBe(0);
expect(renderCount()).toBe(0);
drawWhileHidden(true);
tick();
expect(app.drawn).toBe(1);
drawWhileHidden(false);
page.hidden = false;
tick();
expect(app.drawn).toBe(2);
} finally {
vi.unstubAllGlobals();
}
});
it('afterFrames gives up rather than hanging when no frame ever comes', async () => {
vi.useFakeTimers();
try {
const { app } = fakeApp();
initRenderLoop(app as unknown as Application);
const waited = afterFrames(1, 50);
vi.advanceTimersByTime(60);
await waited;
expect(app.drawn).toBe(0);
} finally {
vi.useRealTimers();
}
});
});
/**
* A stage whose every pixel has a colour of its own (red = x, green = y, blue = x + y), read the
* way Pixi's extract hands it back: row by row across the frame, four bytes a pixel.
*/
function fakeStage(resolution = 1) {
const frames: { x: number; y: number; width: number; height: number }[] = [];
const app = {
stage: {},
renderer: {
resolution,
extract: {
pixels({ frame }: { frame: { x: number; y: number; width: number; height: number } }) {
frames.push({ x: frame.x, y: frame.y, width: frame.width, height: frame.height });
const pixels = new Uint8ClampedArray(frame.width * frame.height * 4);
for (let row = 0; row < frame.height; row++)
for (let col = 0; col < frame.width; col++) {
const x = Math.floor(frame.x) + col;
const y = Math.floor(frame.y) + row;
pixels.set([x, y, x + y, 255], (row * frame.width + col) * 4);
}
return { pixels, width: frame.width, height: frame.height };
},
},
},
};
return { app: app as unknown as Application, frames };
}
describe('stage pixels', () => {
const scattered = [
{ x: 40, y: 7 },
{ x: 3, y: 90 },
{ x: 40, y: 7 },
{ x: 120, y: 33 },
{ x: 3, y: 7 },
];
it('reads many points from one extract, each the colour stagePixel gives it', () => {
const one = fakeStage();
const alone = scattered.map((p) => stagePixel(one.app, p.x, p.y));
const batch = fakeStage();
expect(stagePixels(batch.app, scattered)).toEqual(alone);
expect(alone[1]).toBe('#035a5d');
expect(batch.frames).toEqual([{ x: 3, y: 7, width: 118, height: 84 }]);
});
it('keeps the point-by-point read for fractions, which would sample elsewhere in a shared frame', () => {
const { app, frames } = fakeStage();
stagePixels(app, [
{ x: 10, y: 10 },
{ x: 12.5, y: 10 },
]);
expect(frames.length).toBe(2);
});
it('keeps it at a resolution other than 1, where one pixel is a block of values', () => {
const { app, frames } = fakeStage(2);
stagePixels(app, scattered);
expect(frames.length).toBe(scattered.length);
});
it('answers a single point and no point at all', () => {
const { app } = fakeStage();
expect(stagePixels(app, [{ x: 5, y: 6 }])).toEqual(['#05060b']);
expect(stagePixels(app, [])).toEqual([]);
});
});