Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with window from/to edge labels); clicks land with pixel magnetism (MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on long books and never fired, which was the 'chapter scrub isn't accurate' bug. Hover inside the radius locks the tooltip to the line with a highlight. Shift+click jumps to the nearest line from anywhere. Touch: drag is an incremental cursor (applyDrag) whose scale tightens continuously as the finger pulls off the bar (dragScale, exponential 10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the zoom (touchRailSpan) and lifts toward the finger (railLiftPx). Dialog retitled 'Shortcuts & interactions' with a Mouse & touch section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold snapToChapter deleted. 133 unit tests; 19 Playwright checks on the KJV (1189ch) and Gardens (34ch) books.
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
applyDrag,
|
|
bufferedAheadEnd,
|
|
bufferedSpanPct,
|
|
chapterIndexFor,
|
|
denseTicks,
|
|
dragScale,
|
|
DRAG_REF_PX,
|
|
DRAG_SCALE_MIN,
|
|
nearestChapterLine,
|
|
railLiftPx,
|
|
railSecondsAtPointer,
|
|
railTicks,
|
|
railWindow,
|
|
RAIL_SPAN,
|
|
secondsAtPointer,
|
|
snapToChapterPx,
|
|
TOUCH_RAIL_LIFT_MAX_PX,
|
|
TOUCH_RAIL_MIN_DY_PX,
|
|
touchRailSpan,
|
|
} from "./audio-scrubber";
|
|
|
|
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
|
|
// single-file book (Gardens-shaped: 34 chapters).
|
|
const duration86h = 310_320;
|
|
const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 }));
|
|
const duration26h = 93_807;
|
|
const chapters34 = Array.from({ length: 34 }, (_, i) => ({
|
|
start: (i * duration26h) / 34,
|
|
title: `Chapter ${i + 1}`,
|
|
}));
|
|
|
|
const rect = (width: number): DOMRect =>
|
|
({
|
|
left: 100,
|
|
right: 100 + width,
|
|
width,
|
|
top: 0,
|
|
bottom: 10,
|
|
height: 10,
|
|
x: 100,
|
|
y: 0,
|
|
toJSON: () => ({}),
|
|
}) as DOMRect;
|
|
|
|
describe("secondsAtPointer", () => {
|
|
it("maps the bar linearly and clamps outside it", () => {
|
|
const r = rect(800);
|
|
expect(secondsAtPointer(100, r, duration86h)).toBe(0);
|
|
expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5);
|
|
expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h);
|
|
expect(secondsAtPointer(50, r, duration86h)).toBe(0);
|
|
expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h);
|
|
});
|
|
|
|
it("is safe with degenerate rects and durations", () => {
|
|
expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0);
|
|
expect(secondsAtPointer(150, rect(800), 0)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("chapterIndexFor", () => {
|
|
it("finds the containing chapter in both shapes", () => {
|
|
expect(chapterIndexFor(chapters1189, 0)).toBe(0);
|
|
expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500);
|
|
expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33);
|
|
expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33);
|
|
});
|
|
|
|
it("returns -1 before the first chapter and for none at all", () => {
|
|
expect(chapterIndexFor(chapters1189, -1)).toBe(-1);
|
|
expect(chapterIndexFor([], 100)).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
|
|
// 34-chapter book on an 800px bar: ~23.5px between lines — the
|
|
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
|
|
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
|
|
// is correct for a book that dense.)
|
|
const pxSec = 800 / duration26h; // bar px per second
|
|
|
|
it("reports the closest line and its distance in bar pixels", () => {
|
|
const start = chapters34[5].start;
|
|
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
|
|
expect(near!.index).toBe(5);
|
|
expect(near!.seconds).toBe(start);
|
|
expect(near!.distancePx).toBeCloseTo(5, 5);
|
|
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
|
|
});
|
|
|
|
it("finds sub-pixel lines on a book as dense as the KJV", () => {
|
|
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
|
|
// radius always can. This is the §9 root-cause fix.
|
|
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
|
|
expect(dense).not.toBeNull();
|
|
expect(dense!.distancePx).toBeLessThan(0.25);
|
|
});
|
|
|
|
it("snaps within the pixel radius and not beyond it", () => {
|
|
const start = chapters34[5].start;
|
|
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
|
|
seconds: start,
|
|
snapped: true,
|
|
});
|
|
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
|
|
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
|
|
seconds: start,
|
|
snapped: true,
|
|
});
|
|
});
|
|
|
|
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
|
|
const a = chapters34[5].start;
|
|
const b = chapters34[6].start;
|
|
const mid = (a + b) / 2;
|
|
// Radius 0 + force must land where a wide radius lands: distance
|
|
// is ignored, nearest wins (mid+1 → the later line).
|
|
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
|
|
seconds: b,
|
|
snapped: true,
|
|
});
|
|
// Past the book's last line, the nearest line is still reachable.
|
|
const last = chapters34[33].start;
|
|
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
|
|
seconds: last,
|
|
snapped: true,
|
|
});
|
|
});
|
|
|
|
it("picks the NEAREST line when two are in radius", () => {
|
|
const a = chapters34[5].start;
|
|
const b = chapters34[6].start;
|
|
const mid = (a + b) / 2;
|
|
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
|
|
// the closer (later) one must win.
|
|
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
|
|
expect(result.snapped).toBe(true);
|
|
expect(result.seconds).toBe(b);
|
|
});
|
|
|
|
it("leaves the position alone with no chapters or a dead bar", () => {
|
|
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
|
|
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
|
|
it("is 1:1 at the bar and decays an order per reference distance", () => {
|
|
expect(dragScale(0)).toBe(1);
|
|
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
|
|
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
|
|
});
|
|
|
|
it("is symmetric in pull direction and monotonic in |dy|", () => {
|
|
expect(dragScale(-60)).toBe(dragScale(60));
|
|
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
|
|
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
|
|
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
|
|
});
|
|
|
|
it("degenerates to plain travel with a dead reference", () => {
|
|
expect(dragScale(500, 0)).toBe(1);
|
|
});
|
|
|
|
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
|
|
// 800px bar over 26h: 100px ≈ 11726s.
|
|
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
|
|
(100 / 800) * duration26h,
|
|
5,
|
|
);
|
|
});
|
|
|
|
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
|
|
const pxPerSecond = 800 / duration26h;
|
|
const onePxSeconds = 1 / pxPerSecond;
|
|
// One reference-distance of pull = one order of magnitude finer.
|
|
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
|
|
100 * onePxSeconds * 0.1,
|
|
5,
|
|
);
|
|
// Scale changes only affect SUBSEQUENT travel: the cursor never
|
|
// teleports when the finger pulls away mid-drag.
|
|
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
|
|
});
|
|
|
|
it("clamps to the book in both directions", () => {
|
|
const pxPerSecond = 800 / duration26h;
|
|
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
|
|
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
|
|
expect(applyDrag(50, 100, 0, 0, duration26h)).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("touch rail feedback (span shrink + lift)", () => {
|
|
it("starts at the full ±5-min span and shrinks with the zoom", () => {
|
|
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
|
|
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
|
|
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
|
|
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
|
|
});
|
|
|
|
it("shows the rail only past the pull threshold", () => {
|
|
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("lifts toward the finger, capped", () => {
|
|
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
|
|
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
|
|
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
|
|
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
|
|
});
|
|
});
|
|
|
|
describe("denseTicks", () => {
|
|
it("decimates an 1189-chapter book to a readable number of marks", () => {
|
|
const ticks = denseTicks(chapters1189, duration86h, 800, 4);
|
|
expect(ticks.length).toBeGreaterThan(50);
|
|
expect(ticks.length).toBeLessThan(300);
|
|
expect(ticks[0]).toBe(0);
|
|
// kept marks are strictly increasing and spaced by the gap rule
|
|
for (let i = 1; i < ticks.length; i += 1) {
|
|
const gapPx =
|
|
((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800;
|
|
expect(gapPx).toBeGreaterThanOrEqual(4);
|
|
}
|
|
});
|
|
|
|
it("keeps every chapter for a sparse 34-chapter book", () => {
|
|
const ticks = denseTicks(chapters34, duration26h, 800, 4);
|
|
expect(ticks).toHaveLength(34);
|
|
});
|
|
|
|
it("handles degenerate inputs", () => {
|
|
expect(denseTicks([], duration86h, 800)).toEqual([]);
|
|
expect(denseTicks(chapters34, 0, 800)).toEqual([]);
|
|
expect(denseTicks(chapters34, duration26h, 0)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("bufferedAheadEnd", () => {
|
|
it("returns the end of the range containing the playhead", () => {
|
|
const ranges = [
|
|
{ start: 0, end: 2 },
|
|
{ start: 92_719, end: 93_808 },
|
|
];
|
|
// A huge m4b's moov index at the tail is its own range — it must
|
|
// NOT read as "the whole book is buffered".
|
|
expect(bufferedAheadEnd(ranges, 0)).toBe(2);
|
|
expect(bufferedAheadEnd(ranges, 1.5)).toBe(2);
|
|
expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808);
|
|
});
|
|
|
|
it("walks into the next range when the playhead sits at a boundary", () => {
|
|
expect(
|
|
bufferedAheadEnd(
|
|
[
|
|
{ start: 0, end: 10 },
|
|
{ start: 10, end: 30 },
|
|
],
|
|
10,
|
|
),
|
|
).toBe(30);
|
|
});
|
|
|
|
it("returns null when the playhead is in a gap or ranges are empty", () => {
|
|
expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull();
|
|
expect(bufferedAheadEnd([], 10)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("bufferedSpanPct", () => {
|
|
it("maps only the active track's segment on a multi-track book", () => {
|
|
// Track 8 of the KJV shape spans ~2088s..2344s of 310320s.
|
|
const track = { start: 2088, end: 2344 };
|
|
const span = bufferedSpanPct(track, 2344, duration86h);
|
|
expect(span).not.toBeNull();
|
|
expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5);
|
|
expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
|
|
// Element buffer beyond the track end is clamped to the track.
|
|
const clamped = bufferedSpanPct(track, 99999, duration86h);
|
|
expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5);
|
|
});
|
|
|
|
it("spans the whole bar for a single-file book (start 0, end unknown)", () => {
|
|
const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h);
|
|
expect(span!.left).toBe(0);
|
|
expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5);
|
|
});
|
|
|
|
it("returns null with no track or no buffer", () => {
|
|
expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull();
|
|
expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull();
|
|
expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("railWindow / railSecondsAtPointer / railTicks", () => {
|
|
it("centers a ±5min window and clamps at the book edges", () => {
|
|
const mid = railWindow(duration26h / 2, duration26h);
|
|
expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5);
|
|
expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5);
|
|
const atStart = railWindow(0, duration26h);
|
|
expect(atStart.from).toBe(0);
|
|
expect(atStart.to).toBe(RAIL_SPAN);
|
|
const atEnd = railWindow(duration26h, duration26h);
|
|
expect(atEnd.to).toBe(duration26h);
|
|
expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN);
|
|
// A book shorter than the window clamps to the whole book.
|
|
expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 });
|
|
});
|
|
|
|
it("maps pointer X at minutes-per-pixel inside the window", () => {
|
|
const r = rect(600);
|
|
const window = { from: 3000, to: 3600 };
|
|
expect(railSecondsAtPointer(100, r, window)).toBe(3000);
|
|
expect(railSecondsAtPointer(400, r, window)).toBe(3300);
|
|
expect(railSecondsAtPointer(700, r, window)).toBe(3600);
|
|
});
|
|
|
|
it("lists the chapter starts inside the window with titles", () => {
|
|
// from is 1s past chapter 4's start, so chapter 4 is outside.
|
|
const ticks = railTicks(
|
|
{ from: chapters34[3].start + 1, to: chapters34[6].start + 1 },
|
|
chapters34,
|
|
);
|
|
expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]);
|
|
});
|
|
});
|