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.
206 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|