Files
feuerwerk/tests/viewport/video-library.test.ts
T
adminandClaude Opus 5.5 875c76026a Silence a free video once its sprite is destroyed (#420)
A sprite whose Video clock is free played its video on after it was
destroyed, heard in a game, until the player was let go 2 to 4 s
later. A driven one was already held and quiet after 100 ms.

- The library holds and quiets a free player nothing asked for in
  HUSH_MS, as it did a driven one. That covers Destroy, a scene load,
  the end of a run and a sprite switched off.
- Asked for again before it is let go, it plays on from where it was
  held, heard only in a run. A hidden page leaves it alone, as the
  sweep does.
- A sprite only out of view keeps its free video playing and heard.
  Whether a video is heard should not hang on the camera. Before, it
  ran 2 to 4 s and was then let go. Now it plays on, which opens its
  player even where it was never in view.

Tests:
- Unit: video-library 1 new. A free player losing its wearer is held
  and quiet 200 ms later, stays so through a new run, plays on heard
  when asked again with the same element, and runs on on a hidden
  page.
- e2e 66 on melektaus-atx: ALL PASSED, 36 checks, 66g-ii new. Heard
  31 frames before Destroy, held and muted in every frame read after.
- Counter-probe without hushing free players: the unit test and only
  66g-ii fail, the player plays on heard (paused false, muted false).
- Gate on melektaus-atx: 309 files, 4797 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:49:12 +02:00

333 lines
12 KiB
TypeScript

import type * as Pixi from 'pixi.js';
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.
*/
// Pixi's own video source reads the page as it is made; a plain source stands in for it.
vi.mock('pixi.js', async (load) => {
const pixi = await load<typeof Pixi>();
class VideoSource extends pixi.TextureSource {
constructor() {
super({ width: 320, height: 180 });
}
}
return { ...pixi, VideoSource };
});
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', 'none');
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();
vi.restoreAllMocks();
});
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', 'none')).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', 'none');
const b = videos.wear(REF, { videoClock: 'driven', videoTime: 1.25 }, 'view1:b', 'none');
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],
]);
});
it('reads the speed by the time of the frame, not by when the frame got round to it', async () => {
const frame = { at: 0 };
vi.stubGlobal('document', {
hidden: false,
addEventListener: () => {},
createElement: fakeVideo,
timeline: {
get currentTime() {
return frame.at;
},
},
});
// The first frames are drawn 45 ms late, as on a busy page; the time runs at 1x by the frames.
let late = 45;
vi.spyOn(performance, 'now').mockImplementation(() => frame.at + late);
const videos = await freshLibrary();
for (let i = 0; i <= 36; i++) {
frame.at = (i * 1000) / 60;
if (i === 7) late = 0;
videos.wear(REF, { videoClock: 'driven', videoTime: i / 60 }, 'view1:a', 'none');
}
expect(videos.debugInfo()[0]!.speed).toBeCloseTo(1, 6);
});
it('lands a seek that Chrome hands over no frame for', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'clearInterval'] });
const events = new Map<string, (() => void)[]>();
let frames: (() => void)[] = [];
const element = {
...fakeVideo(),
seeking: false,
ended: false,
duration: 2,
playbackRate: 1,
readyState: 4,
videoWidth: 320,
videoHeight: 180,
at: 0,
get currentTime() {
return this.at;
},
set currentTime(time: number) {
this.at = time;
this.seeking = true;
},
addEventListener: (type: string, listener: () => void) =>
events.set(type, [...(events.get(type) ?? []), listener]),
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
};
const fire = (type: string) => events.get(type)?.forEach((listener) => listener());
const seekDone = () => {
element.seeking = false;
fire('seeked');
};
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element });
const videos = await freshLibrary();
const key = videos.wear(REF, { videoClock: 'driven', videoTime: 0.5 }, 'view1:a', 'none').file;
fire('loadeddata');
seekDone();
[frames] = [[], ...frames.map((frame) => frame())];
expect(videos.showsAsked(key)).toBe(true);
// Switched on at 1.2 s: the seek is done, and no frame follows it.
videos.wear(REF, { videoClock: 'driven', videoTime: 1.2 }, 'view1:a', 'none');
expect(videos.showsAsked(key)).toBe(false);
seekDone();
vi.advanceTimersByTime(200);
vi.useRealTimers();
expect(videos.showsAsked(key)).toBe(true);
});
it('holds a driven player quiet as soon as its wearer is gone, as an ended composition item', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
const events = new Map<string, () => void>();
let frames: (() => void)[] = [];
const element = {
...fakeVideo(),
ended: false,
duration: 10,
playbackRate: 1,
seeking: false,
readyState: 4,
videoWidth: 320,
videoHeight: 180,
play() {
this.paused = false;
return Promise.resolve();
},
pause() {
this.paused = true;
},
addEventListener: (type: string, listener: () => void) => events.set(type, listener),
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
};
vi.stubGlobal('document', { hidden: false, addEventListener: () => {}, createElement: () => element });
const { soundLibrary } = await import('../../src/viewport/SoundLibrary');
vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null);
const videos = await freshLibrary();
const props = { videoClock: 'driven', videoTime: 0 };
videos.wear(REF, props, 'view1:a', 'game');
events.get('loadeddata')!();
[frames] = [[], ...frames.map((frame) => frame())];
// Worn at 1x for a second, heard.
for (let ms = 0; ms <= 1000; ms += 1000 / 60) {
element.currentTime = ms / 1000;
videos.wear(REF, { ...props, videoTime: ms / 1000 }, 'view1:a', 'game');
vi.advanceTimersByTime(1000 / 60);
}
expect([element.paused, element.muted]).toEqual([false, false]);
// The item ends: nothing wears the player any more. Let go only after 2 s, it played on heard.
vi.advanceTimersByTime(200);
expect([element.paused, element.muted]).toEqual([true, true]);
expect(videos.debugInfo()).toHaveLength(1);
vi.useRealTimers();
});
it('holds a free player quiet once no sprite wears it, and plays it on when one does (#420)', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'performance'] });
const page = { hidden: false };
const events = new Map<string, () => void>();
let frames: (() => void)[] = [];
const element = {
...fakeVideo(),
play() {
this.paused = false;
return Promise.resolve();
},
pause() {
this.paused = true;
},
addEventListener: (type: string, listener: () => void) => events.set(type, listener),
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
};
vi.stubGlobal('document', {
get hidden() {
return page.hidden;
},
addEventListener: () => {},
createElement: () => element,
});
const { soundLibrary } = await import('../../src/viewport/SoundLibrary');
vi.spyOn(soundLibrary, 'mediaInput').mockReturnValue(null);
const videos = await freshLibrary();
const drawFor = (ms: number) => {
for (let at = 0; at < ms; at += 1000 / 60) {
videos.get(KEY);
vi.advanceTimersByTime(1000 / 60);
}
};
videos.get(KEY);
events.get('loadeddata')!();
[frames] = [[], ...frames.map((frame) => frame())];
videos.startRun();
drawFor(1000);
const state = () => [element.paused, element.muted];
expect(state()).toEqual([false, false]);
// Its sprite is destroyed: held and quiet at once, not heard until it is let go 2-4 s later.
vi.advanceTimersByTime(200);
expect(state()).toEqual([true, true]);
// A run started meanwhile takes it back to its start, still quiet.
videos.startRun();
vi.advanceTimersByTime(200);
expect(state()).toEqual([true, true]);
// Worn again before it is let go: it plays on, heard, the same video.
videosMade = 0;
drawFor(500);
expect([...state(), videosMade]).toEqual([false, false, 0]);
// A hidden page draws nothing, and asks for nothing: it plays on as before.
page.hidden = true;
vi.advanceTimersByTime(1000);
expect(state()).toEqual([false, false]);
vi.useRealTimers();
});
it('tells a view whether one of its driven players still waits for its first picture', async () => {
const videos = await freshLibrary();
videos.get(KEY);
expect(videos.loading('view12')).toBe(false);
const worn = videos.wear(REF, { videoClock: 'driven', videoTime: 0 }, 'view12:a', 'none');
expect(videos.hasPicture(worn.file)).toBe(false);
// A run waits only for its own view: view12 is neither view1 nor view2.
expect([videos.loading('view12'), videos.loading('view1'), videos.loading('view2')]).toEqual([
true,
false,
false,
]);
});
});