Files
bookhoard/web/src/audio-sleep.test.ts
T
John O'Keefe f8de17723a feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
2026-10-23 11:06:42 -04:00

165 lines
5.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armClockTime,
armEndOfChapter,
armMinutes,
fadeVolume,
formatRemaining,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
} from "./audio-sleep";
describe("armMinutes / sleepDue / sleepRemainingMs", () => {
it("arms a wall-clock deadline", () => {
const state = armMinutes(1_000_000, 5);
expect(state.mode).toBe("minutes");
expect(state.endsAtMs).toBe(1_000_000 + 5 * 60_000);
expect(sleepDue(state, 1_000_000 + 5 * 60_000 - 1)).toBe(false);
expect(sleepDue(state, 1_000_000 + 5 * 60_000)).toBe(true);
expect(sleepDue(state, 1_000_000 + 9 * 60_000)).toBe(true);
});
it("reports remaining time clamped at zero", () => {
const state = armMinutes(0, 10);
expect(sleepRemainingMs(state, 0)).toBe(600_000);
expect(sleepRemainingMs(state, 599_000)).toBe(1000);
expect(sleepRemainingMs(state, 700_000)).toBe(0);
});
it("never fires in non-minutes modes", () => {
expect(sleepDue(SLEEP_OFF, Date.now())).toBe(false);
expect(sleepDue(armEndOfChapter(3), Date.now())).toBe(false);
expect(sleepRemainingMs(SLEEP_OFF, Date.now())).toBeNull();
});
});
describe("armEndOfChapter / sleepChapterFired", () => {
it("fires when the armed chapter is left, in either direction", () => {
const state = armEndOfChapter(7);
expect(sleepChapterFired(state, 7)).toBe(false);
expect(sleepChapterFired(state, 8)).toBe(true);
expect(sleepChapterFired(state, 6)).toBe(true);
});
it("does nothing in other modes", () => {
expect(sleepChapterFired(SLEEP_OFF, 8)).toBe(false);
expect(sleepChapterFired(armMinutes(0, 5), 8)).toBe(false);
});
});
describe("fadeVolume", () => {
it("holds the base at start, ramps linearly, reaches zero at fade end", () => {
expect(fadeVolume(0.5, 0)).toBe(0.5);
expect(fadeVolume(0.5, 2500)).toBeCloseTo(0.25, 10);
expect(fadeVolume(0.5, 5000)).toBe(0);
expect(fadeVolume(0.5, 9000)).toBe(0);
expect(fadeVolume(1, 1000)).toBeCloseTo(0.8, 10);
});
it("clamps negative elapsed to the base and out-of-range volumes", () => {
expect(fadeVolume(0.5, -5)).toBe(0.5);
expect(fadeVolume(2, 1000)).toBeLessThanOrEqual(1);
});
});
describe("formatRemaining / sleepLabel", () => {
it("formats mm:ss, rounding up partial seconds", () => {
expect(formatRemaining(45 * 60_000)).toBe("45:00");
expect(formatRemaining(1000)).toBe("0:01");
expect(formatRemaining(59_500)).toBe("1:00");
expect(formatRemaining(0)).toBe("0:00");
});
it("labels every mode", () => {
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
expect(
sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0),
).toBe("Fading");
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
});
});
describe("armClockTime (§17 stop-at-clock)", () => {
it("arms a minutes-state deadline at the next occurrence of HH:MM", () => {
// 10:00 local → 23:00 same day.
const tenAm = new Date();
tenAm.setHours(10, 0, 0, 0);
const state = armClockTime(tenAm.getTime(), "23:00");
expect(state?.mode).toBe("minutes");
const expected = new Date(tenAm);
expected.setHours(23, 0, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
// Label is the ordinary countdown machinery (12h59m → 779:00).
expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00");
});
it("wraps to tomorrow when the time already passed", () => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
const state = armClockTime(noon.getTime(), "09:15");
const expected = new Date(noon);
expected.setDate(expected.getDate() + 1);
expected.setHours(9, 15, 0, 0);
expect(state?.endsAtMs).toBe(expected.getTime());
});
it("rejects unparseable and out-of-range input", () => {
const now = Date.now();
expect(armClockTime(now, "nope")).toBeNull();
expect(armClockTime(now, "24:00")).toBeNull();
expect(armClockTime(now, "10:61")).toBeNull();
});
});
describe("§17 chain: end-of-chapter with a follow-up countdown", () => {
it("arms the follow-up on the state; the chapter fire contract is unchanged", () => {
const state = armEndOfChapter(2, 15);
expect(state.followUpMinutes).toBe(15);
// The sync contract (any chapter change fires) is the same — the
// component converts to armMinutes instead of fading.
expect(sleepChapterFired(state, 3)).toBe(true);
expect(sleepChapterFired(state, 1)).toBe(true);
expect(sleepChapterFired(state, 2)).toBe(false);
});
it("labels the chapter phase with the follow-up length", () => {
expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
});
it("offers the chain row in the dropdown vocabulary", () => {
const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15");
expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true });
});
});
describe("SLEEP_CHOICES", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => {
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
"off",
"5",
"10",
"15",
"30",
"45",
"60",
"eoch",
"eoch15",
]);
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
5, 10, 15, 30, 45, 60,
]);
});
it("has unique keys and labels", () => {
expect(new Set(SLEEP_CHOICES.map((c) => c.key)).size).toBe(SLEEP_CHOICES.length);
expect(new Set(SLEEP_CHOICES.map((c) => c.label)).size).toBe(SLEEP_CHOICES.length);
});
});