Files
siftlode/frontend/src/lib/resume.dom.test.ts
T
peter 5095047c81 test(player): pin the stop URL's ids, the beacon's settle, the kept point's edges
The stop-URL contract passed with the two ids swapped and read the backend
from the working directory; nothing called a real beacon's onSettled or its
library pass-through; the kept-point rule was held by its constants only.
2026-10-08 01:16:30 +02:00

206 lines
6.5 KiB
TypeScript

// @vitest-environment jsdom
//
// The resume of a player on the browser's own media element: it opens at the kept point, reports
// where it stops (a pause, the periodic checkpoint, the end, the page going away, the player
// closing), and reports NOTHING before the item played — a player closed at once must not overwrite
// the kept point with the 0:00 it never left. A shared link's point lives in this browser.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import {
CHECKPOINT_S,
LINK_POINTS_MAX,
keptPosition,
linkPoint,
saveLinkPoint,
useResume,
} from "./resume";
import { LS } from "./storage";
/** A media element whose clock and length the test sets (jsdom plays nothing). */
function fakeMedia(duration = 600) {
const el = document.createElement("video");
let t = 0;
let paused = true;
let ready = 0;
Object.defineProperties(el, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => (ready ? duration : NaN), configurable: true },
readyState: { get: () => ready, configurable: true },
paused: { get: () => paused, configurable: true },
ended: { get: () => ready > 0 && t >= duration, configurable: true },
});
return {
el,
metadata() {
ready = 1;
el.dispatchEvent(new Event("loadedmetadata"));
},
play() {
paused = false;
el.dispatchEvent(new Event("playing"));
},
at(s: number) {
t = s;
el.dispatchEvent(new Event("timeupdate"));
},
pause() {
paused = true;
el.dispatchEvent(new Event("pause"));
},
};
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("useResume", () => {
it("opens at the kept point once the element knows its length", () => {
const m = fakeMedia();
renderHook(() => useResume(m.el, "7", 300, () => {}));
expect(m.el.currentTime).toBe(0);
m.metadata();
expect(m.el.currentTime).toBe(300);
});
it("drops a kept point past the end rather than clamping onto it", () => {
const m = fakeMedia(200);
renderHook(() => useResume(m.el, "7", 300, () => {}));
m.metadata();
expect(m.el.currentTime).toBe(0);
});
it("reports nothing when the player closes before the item played", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 300, save));
m.metadata();
unmount();
expect(save).not.toHaveBeenCalled();
});
it("reports a pause, and the close as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(42);
m.pause();
expect(save).toHaveBeenLastCalledWith(42, 600, false);
unmount();
expect(save).toHaveBeenLastCalledWith(42, 600, true);
});
it("counts an element already playing when it subscribes as played", () => {
// A cached file started from 0 autoplays before the effect runs: its one `playing` is gone.
const m = fakeMedia();
m.metadata();
m.play();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.at(42);
m.pause();
expect(save).toHaveBeenLastCalledWith(42, 600, false);
});
it("checkpoints while it plays, at most once per interval", () => {
vi.useFakeTimers();
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(1);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(CHECKPOINT_S * 1000 - 1);
m.at(10);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
m.at(11);
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith(11, 600, false);
});
it("reports the end as the full length (finished)", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(600);
m.el.dispatchEvent(new Event("ended"));
expect(save).toHaveBeenLastCalledWith(600, 600, false);
});
it("reports the page going away as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(30);
window.dispatchEvent(new Event("pagehide"));
expect(save).toHaveBeenLastCalledWith(30, 600, true);
});
});
describe("keptPosition (the server's rule)", () => {
it("keeps a point in the middle, clears one too early or too near the end", () => {
expect(keptPosition(754.9, 3000)).toBe(754);
expect(keptPosition(4, 3000)).toBe(0);
expect(keptPosition(2995, 3000)).toBe(0);
expect(keptPosition(120, 0)).toBe(120); // an unknown length cannot be "near the end"
});
// The edges themselves, one second either side: a `>=` that became `>` on one side only moves a
// rule both sides must apply alike, and the constants alone (resume.contract.test.ts) would not see
// it. backend/tests/test_library_progress.py holds the server to the same rows.
it.each([
[4, 3000, 0],
[5, 3000, 5],
[2990, 3000, 2990],
[2991, 3000, 0],
])("at %s s of %s keeps %s", (position, duration, kept) => {
expect(keptPosition(position, duration)).toBe(kept);
});
});
describe("a shared link's point", () => {
beforeEach(() => localStorage.clear());
it("is kept per link, and forgotten at a point not worth keeping", () => {
saveLinkPoint("tok-a", 125, 600);
saveLinkPoint("tok-b", 300, 600);
expect(linkPoint("tok-a")).toBe(125);
expect(linkPoint("tok-b")).toBe(300);
saveLinkPoint("tok-a", 598, 600);
expect(linkPoint("tok-a")).toBe(0);
expect(linkPoint("tok-b")).toBe(300);
});
it("keeps only the most recent links", () => {
vi.useFakeTimers();
for (let i = 0; i <= LINK_POINTS_MAX; i++) {
vi.advanceTimersByTime(1000);
saveLinkPoint(`tok-${i}`, 100, 600);
}
expect(linkPoint("tok-0")).toBe(0);
expect(linkPoint(`tok-${LINK_POINTS_MAX}`)).toBe(100);
expect(Object.keys(JSON.parse(localStorage.getItem(LS.watchResume)!))).toHaveLength(
LINK_POINTS_MAX,
);
});
it("reads a broken stored value as from the start", () => {
localStorage.setItem(LS.watchResume, JSON.stringify({ "tok-a": "nonsense", x: [1] }));
expect(linkPoint("tok-a")).toBe(0);
localStorage.setItem(LS.watchResume, "[1,2]");
expect(linkPoint("tok-a")).toBe(0);
saveLinkPoint("tok-a", 100, 600);
expect(linkPoint("tok-a")).toBe(100);
});
});