// @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); }); });