Files
feuerwerk/tests/viewport/video-seek.test.ts
T
adminandClaude Opus 5.5 c0ba2673ba Keep the video tab's picture following while scrubbing (#417)
Scrubbing in the video tab set currentTime on every pointer move.
Each new seek aborts the running one. With a large video a seek takes
longer than the gap between two moves, so none finished and the
picture froze. The composition tab never had this, because its
players run through the VideoLibrary, which only keeps the newest
wish while a seek runs.

- The pending-seek logic moves into a shared helper,
  src/viewport/videoSeek.ts. The VideoLibrary uses it unchanged.
- The video tab seeks through the same helper and uploads the
  picture again after each finished seek.
- Play drops a pending scrub wish. Otherwise the video would jump
  back right after it starts.
- Scrubbing before the first picture no longer jumps back to the
  time the tab opened with. The video stays at the last chosen time.

Tests: gate on melektaus-atx green, 303 files, 4681 tests (2 new in
video-seek). e2e 66 ALL PASSED, 30 checks. Without the helper the
new unit test is red ([0.1, 0.2, 0.3, 0.4, 0.5] instead of [0.1]).
There is no browser check for the freeze itself, the test video
seeks too fast to show it.

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

80 lines
2.5 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import { chaseSeeks, seekChase, seekVideo } from '../../src/viewport/videoSeek';
/**
* Scrubbing a video (Video tab): each seek set while one ran aborted it, and a video that took
* longer to seek than the mouse took to move finished none and hung. Only the newest wish follows.
*/
function fakeVideo() {
const events = new Map<string, (() => void)[]>();
const frames: (() => void)[] = [];
const element = {
seeking: false,
at: 0,
/** Every time a seek was set, in order. */
seeks: [] as number[],
get currentTime() {
return this.at;
},
set currentTime(time: number) {
this.at = time;
this.seeking = true;
this.seeks.push(time);
},
addEventListener: (type: string, listener: () => void) =>
events.set(type, [...(events.get(type) ?? []), listener]),
requestVideoFrameCallback: (callback: () => void) => frames.push(callback),
};
const seekDone = () => {
element.seeking = false;
events.get('seeked')?.forEach((listener) => listener());
};
return { element, seekDone, frames };
}
describe('seeking while scrubbing', () => {
afterEach(() => vi.useRealTimers());
it('lets only the newest of the seeks asked for during one follow it', () => {
const { element, seekDone } = fakeVideo();
const chase = seekChase();
const video = element as unknown as HTMLVideoElement;
chaseSeeks(
video,
chase,
() => false,
() => {},
);
for (const time of [0.1, 0.2, 0.3, 0.4, 0.5]) seekVideo(video, chase, time);
expect(element.seeks).toEqual([0.1]);
seekDone();
expect(element.seeks).toEqual([0.1, 0.5]);
seekDone();
expect(element.seeks).toEqual([0.1, 0.5]);
});
it('loads the picture again with the frame after the last seek, or without one after 150 ms', () => {
vi.useFakeTimers({ toFake: ['setTimeout'] });
const { element, seekDone, frames } = fakeVideo();
const chase = seekChase();
const video = element as unknown as HTMLVideoElement;
let landed = 0;
chaseSeeks(
video,
chase,
() => false,
() => (landed += 1),
);
seekVideo(video, chase, 0.4);
seekDone();
expect([chase.landing, landed]).toEqual([true, 0]);
frames.shift()!();
expect([chase.landing, landed]).toEqual([false, 1]);
// No frame for this one.
seekVideo(video, chase, 0.8);
seekDone();
vi.advanceTimersByTime(200);
expect([chase.landing, landed]).toEqual([false, 2]);
});
});