Files
feuerwerk/tests/viewport/video-library.test.ts
T
adminandClaude Opus 5.5 43fd22a763 Keep a driven video ready while its sprite is off (#404)
A cut between two videos flashed white: the sprite switched on had
no picture yet, because its player only opened then or had been
dropped meanwhile. A driven video now waits muted at its time while
its sprite is off, in the editor and in a run.

- A disabled sprite with Video clock Driven asks for its player
  without drawing it. A Free video still opens when shown.
- A run's draw list keeps disabled driven sprites, marked as off.
  Before, collectWorldItems dropped every disabled object, so the
  game view never held a player for them.
- VideoLibrary.wear() counts as asking. The sweeper dropped a held
  player after 2 to 4 s and it was loading again at the cut.
- The speed of the asked time is read over a window that grows to
  500 ms. Over 100 ms one 1/60 s step more or less read as 17 % off,
  the playback rate jumped and the sound dropped out.
- A stream feeds every packet it has loaded to the decoder at once
  and waits once. One round trip per 20 ms packet let the decoder
  fall behind on a busy main thread, which made e2e 34 flaky in CI.

Tests:
- Unit streaming, video-library, video-follow: green, 4 new
- e2e 34 + 66 (atx): ALL PASSED, stream skipped 0, 66m new
- e2e 66 (atx), two runs: ALL PASSED, 25 checks, 66n new
- Gate (atx): CHECK EXIT 0, 297 files, 4557 tests
- Counter-checks: without the draw list fix 66n finds no player,
  without the wear() fix and with the old speed reading the new
  unit tests turn red

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

131 lines
4.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* When the video library lets a player go (#402): after 2 s nobody asked for it, but never while
* the page is hidden. Chrome draws no frames for a hidden page and so nothing asks, while the
* sweeper's timer goes on; a sprite still wearing the video lost it and Pixi warned about the
* destroyed source on the way back.
*/
const KEY = `video:a1b2c3d4e5f6/${'c7'.repeat(32)}.mp4#0~0~1~1`;
let videosMade = 0;
function fakeVideo() {
videosMade += 1;
return {
muted: false,
playsInline: false,
preload: '',
src: '',
currentTime: 0,
loop: false,
paused: true,
addEventListener: () => {},
requestVideoFrameCallback: () => 0,
play: () => Promise.resolve(),
pause: () => {},
removeAttribute: () => {},
load: () => {},
};
}
const page = { hidden: false, listeners: [] as (() => void)[] };
function setHidden(hidden: boolean): void {
page.hidden = hidden;
for (const listener of page.listeners) listener();
}
async function freshLibrary() {
const { VideoLibrary } = await import('../../src/viewport/VideoLibrary');
return new VideoLibrary();
}
describe('VideoLibrary sweeping', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
page.hidden = false;
page.listeners = [];
vi.stubGlobal('document', {
get hidden() {
return page.hidden;
},
addEventListener: (_type: string, listener: () => void) => page.listeners.push(listener),
createElement: fakeVideo,
});
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('lets a player go that nobody asked for in 2 s', async () => {
const videos = await freshLibrary();
videos.get(KEY);
vi.advanceTimersByTime(4100);
expect(videos.debugInfo()).toEqual([]);
});
it('keeps it while the page is hidden, however long', async () => {
const videos = await freshLibrary();
videos.get(KEY);
setHidden(true);
vi.advanceTimersByTime(60_000);
expect(videos.debugInfo()).toHaveLength(1);
});
it('does not count the time hidden as idle once the page is back', async () => {
const videos = await freshLibrary();
videos.get(KEY);
setHidden(true);
vi.advanceTimersByTime(60_000);
setHidden(false);
vi.advanceTimersByTime(1900);
expect(videos.debugInfo()).toHaveLength(1);
vi.advanceTimersByTime(2200);
expect(videos.debugInfo()).toEqual([]);
});
it('keeps a driven player that is only worn, as by a sprite switched off', async () => {
const videos = await freshLibrary();
const ref = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null };
const props = { videoClock: 'driven', videoTime: 1.2 };
videosMade = 0;
for (let ms = 0; ms < 6000; ms += 100) {
videos.wear(ref, props, 'view2:a', false);
vi.advanceTimersByTime(100);
}
// Let go and opened again would be a second video, loading while the sprite shows white.
expect(videosMade).toBe(1);
expect(videos.debugInfo().map(({ key }) => key)).toEqual([`${KEY}@view2:a`]);
});
});
describe('VideoLibrary driven players (#404)', () => {
beforeEach(() => {
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: fakeVideo });
});
afterEach(() => vi.unstubAllGlobals());
const REF = { file: KEY, smooth: true, rect: null, sourceWidth: 320, sourceHeight: 180, slice: null };
it('leaves a sprite with a free clock on the shared player', async () => {
const videos = await freshLibrary();
expect(videos.wear(REF, { videoTime: 1 }, 'view1:a', false)).toBe(REF);
expect(videos.debugInfo()).toEqual([]);
});
it('gives every wearer of a driven video a player of its own, opened at its time', async () => {
const videos = await freshLibrary();
const a = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', false);
const b = videos.wear(REF, { videoClock: 'driven', videoTime: 1.25 }, 'view1:b', false);
expect([a.file, b.file]).toEqual([`${KEY}@view1:a`, `${KEY}@view1:b`]);
expect(videos.debugInfo().map(({ key, time }) => [key, time])).toEqual([
[`${KEY}@view1:a`, 0.5],
[`${KEY}@view1:b`, 1.25],
]);
});
});