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