import { describe, it, expect } from "vitest"; import { DOUBLE_TAP_WINDOW_MS, SEEK_FORWARD_SECONDS, SEEK_BACKWARD_SECONDS, createTapGestureState, registerTap, resolveSeekTarget, clampSeekTarget, END_SEEK_MARGIN_SECONDS, } from "./tapGestures"; const SCREEN_WIDTH = 1000; const LEFT = 100; const RIGHT = 900; function tap(state: ReturnType, x: number, at: number) { return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at }); } /** Narrow a tap outcome to the seek variant, failing the test if it is not one. */ function asSeek(outcome: ReturnType) { if (outcome.action !== "seek") { throw new Error(`expected a seek outcome, got "${outcome.action}"`); } return outcome; } describe("tap gesture resolution", () => { it("defers the single-tap action until the double-tap window has elapsed", () => { const state = createTapGestureState(); const first = tap(state, RIGHT, 1000); // The first tap must NOT immediately toggle play/pause — it may still // become a double tap. expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS }); }); it("resolves an isolated tap to togglePlayPause once the window expires", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS); expect(resolved).toEqual({ action: "togglePlayPause" }); }); it("seeks forward 30s on a double tap on the right half and never pauses", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); const second = asSeek(tap(state, RIGHT, 1150)); expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS); expect(second.seekSeconds).toBe(30); expect(second.feedback).toBe("right"); // The deferred single-tap pause must have been cancelled. expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull(); }); it("seeks back 10s on a double tap on the left half", () => { const state = createTapGestureState(); tap(state, LEFT, 1000); const second = asSeek(tap(state, LEFT, 1100)); expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS); expect(second.seekSeconds).toBe(-10); expect(second.feedback).toBe("left"); }); it("treats a second tap after the window as a new pending single tap", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1); expect(late.action).toBe("pending"); }); it("does not treat a third tap as another double tap", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); expect(tap(state, RIGHT, 1100).action).toBe("seek"); // Triple tap: the third tap starts a fresh pending tap rather than // seeking again off the consumed second tap. expect(tap(state, RIGHT, 1200).action).toBe("pending"); }); it("accumulates repeated double taps on the same side", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); const a = asSeek(tap(state, RIGHT, 1100)); tap(state, RIGHT, 1200); const b = asSeek(tap(state, RIGHT, 1300)); expect(a.seekSeconds).toBe(30); expect(b.seekSeconds).toBe(30); }); it("uses the tap side, so a double tap split across halves follows the second tap", () => { const state = createTapGestureState(); tap(state, LEFT, 1000); const second = asSeek(tap(state, RIGHT, 1100)); expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS); expect(second.feedback).toBe("right"); }); it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => { const state = createTapGestureState(); tap(state, RIGHT, 1000); state.cancel(); expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull(); }); }); describe("seek target resolution", () => { const DURATION = 600; it("adds the delta to the reported position", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130); }); it("clamps to zero when rewinding past the start", () => { expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0); }); it("clamps short of the duration when skipping past the end", () => { // Never land exactly on `duration`: hls.js would then request the segment // that starts at/after the media end, which the server never produces — // the fetch times out and the gap-controller stalls in a pause loop. expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe( DURATION - END_SEEK_MARGIN_SECONDS ); }); it("keeps the end clamp strictly inside the media for a long transcoded item", () => { // Regression: seeking near the end of a ~105min transcoded item clamped to // the exact runtime (6330.324s), making hls.js fetch segment 1055 which // starts at 6336.33s — past the end. That segment 404s/times out forever. const runtime = 6330.324; const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime }); expect(target).toBeLessThan(runtime); expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5); }); it("does not clamp below zero for media shorter than the end margin", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0); }); it("chains off a pending target so rapid taps do not compound off a stale position", () => { // The player has not yet reported the first seek's result, so the // reported position is still the pre-seek value. const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION }); const second = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION, pendingTarget: first, }); expect(second).toBe(160); }); it("ignores a pending target once the player has caught up past it", () => { const target = resolveSeekTarget({ delta: 30, reportedPosition: 200, duration: DURATION, pendingTarget: 130, }); expect(target).toBe(230); }); it("falls back to the delta alone when duration is unknown", () => { expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130); }); }); describe("seek target clamping (shared by skip and seek-bar drag)", () => { it("keeps a mid-stream target untouched", () => { expect(clampSeekTarget(100, 600)).toBe(100); }); it("pulls a drag to the very end back inside the media", () => { // The seek bar's max IS the duration, so dragging fully right yields // exactly `duration` — the value that triggers the dead-segment stall. expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5); }); it("clamps negative and non-finite targets to zero", () => { expect(clampSeekTarget(-5, 600)).toBe(0); expect(clampSeekTarget(NaN, 600)).toBe(0); }); it("leaves the target alone when the duration is unknown", () => { expect(clampSeekTarget(500, 0)).toBe(500); }); });