import type { Application } from 'pixi.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterFrames, cancelFrameWork, 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('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([]); }); });