import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { Dailies, splitByCadence, tasksShown, type DailyItem, } from "../src/client/components/Dailies.tsx"; import { EditPanel, laneGroups, type StripEditor, type StripEvent, } from "../src/client/components/EditPanel.tsx"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; import { metaFor } from "../src/shared/games.ts"; import { goalSummary, type GoalStatus } from "../src/shared/goal.ts"; import type { Task } from "../src/shared/task.ts"; /** * Editing the dailies strip from the strip itself. * * Setting up Genshin's “To Temper Thyself and Journey Far” — five days of seven, * weeks from Monday — used to mean finding it in the list, opening its sheet, * saying it repeats, then setting the goal. The strip's Edit panel does the * same with the same handlers, and weekly and monthly tasks get a row of their * own under the dailies that the reader can switch off. */ const NOW = Date.parse("2026-10-14T12:00:00.000Z"); function render(node: React.ReactElement): string { return renderToStaticMarkup( metaFor(id, {})}>{node}, ); } const temper: StripEvent = { id: "genshin:to-temper-thyself-and-journey-far:2026-08-10", game: "genshin", title: "To Temper Thyself and Journey Far", goal: { period: "week", measure: "days", required: 5, weekStartsOn: 1 }, hasStoredGoal: true, detected: false, defaultWeekStartsOn: 1, maxDays: 84, }; const login: StripEvent = { id: "hsr:gift-of-odyssey:2026-10-01", game: "hsr", title: "Gift of Odyssey", goal: undefined, hasStoredGoal: false, detected: true, defaultWeekStartsOn: 3, maxDays: 7, }; const abyss: StripEvent = { id: "genshin:spiral-abyss:2026-10-01", game: "genshin", title: "Spiral Abyss", goal: undefined, hasStoredGoal: false, detected: false, defaultWeekStartsOn: 3, maxDays: null, }; function editor(over: Partial = {}): StripEditor { return { inStrip: [temper, login], addable: [abyss], onGoal: () => {}, onDaily: () => {}, onOpen: () => {}, shown: { chores: true, weekly: true, monthly: true }, hasTasks: { weekly: true, monthly: false }, onShown: () => {}, ...over, }; } describe("goalSummary", () => { test("says what each goal asks for in a few words", () => { expect(goalSummary(undefined)).toBe("No goal"); expect(goalSummary({ period: "week", measure: "days", required: 5, weekStartsOn: 1 })).toBe( "5 days each week", ); expect(goalSummary({ period: "week", measure: "days", required: 1, weekStartsOn: 1 })).toBe( "1 day each week", ); expect(goalSummary({ period: "event", measure: "days", required: 12 })).toBe( "12 days across the event", ); expect( goalSummary({ period: "week", measure: "amount", target: 2100, weekStartsOn: 1 }), ).toBe("2,100 points each week"); expect(goalSummary({ period: "event", measure: "amount", target: 500 })).toBe( "500 points across the event", ); }); }); describe("laneGroups", () => { test("the reader's lane order first, then stragglers, never re-sorted inside a lane", () => { const zzz: StripEvent = { ...abyss, id: "z", game: "zzz", title: "Z" }; const groups = laneGroups(["hsr", "genshin"], [temper, zzz, login, abyss]); expect(groups.map((g) => [g.game, g.events.map((e) => e.title)])).toEqual([ ["hsr", ["Gift of Odyssey"]], ["genshin", ["To Temper Thyself and Journey Far", "Spiral Abyss"]], ["zzz", ["Z"]], ]); }); test("a lane with nothing in it is dropped", () => { expect(laneGroups(["hsr", "genshin"], [temper]).map((g) => g.game)).toEqual(["genshin"]); }); }); describe("tasksShown", () => { const AT = "2026-10-01T00:00:00.000Z"; const weekly: Task = { id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points", period: { unit: "week", startsOn: 1 }, measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT, }; const monthly: Task = { id: "mytask:bbbbbb2222", game: "zzz", title: "Shop reset", period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 }, at: AT, updatedAt: AT, }; test("each switch hides its own period and nothing else", () => { const all = [weekly, monthly]; expect(tasksShown(all, { weekly: true, monthly: true })).toEqual(all); expect(tasksShown(all, { weekly: false, monthly: true })).toEqual([monthly]); expect(tasksShown(all, { weekly: true, monthly: false })).toEqual([weekly]); expect(tasksShown(all, { weekly: false, monthly: false })).toEqual([]); }); }); describe("splitByCadence", () => { const item = (key: string, cadence: DailyItem["cadence"]): DailyItem => ({ key, game: "zzz", label: key, title: key, ariaLabel: key, today: "2026-10-14", resetsIn: 0, notBefore: null, cadence, }); test("today's things stay on top, weekly and monthly go under, order kept", () => { const items = [item("chore", "day"), item("wk", "week"), item("ev", "event"), item("mo", "month"), item("ev2", "day")]; const { today, longer } = splitByCadence(items); expect(today.map((i) => i.key)).toEqual(["chore", "ev", "ev2"]); expect(longer.map((i) => i.key)).toEqual(["wk", "mo"]); }); }); describe("EditPanel", () => { test("lists the strip's events by game, each with its goal in words", () => { const html = render(); expect(html).toContain("To Temper Thyself and Journey Far"); expect(html).toContain("5 days each week"); expect(html).toContain("Gift of Odyssey"); expect(html).toContain("No goal"); // Genshin's heading comes before Honkai's, as the reader ordered them. expect(html.indexOf("Genshin Impact")).toBeLessThan(html.indexOf("Honkai: Star Rail")); }); test("offers running events that are not in the strip yet, a game at a time", () => { // Closed, a game says how many it could add and lists none of them: with // every game on, the open list was a hundred pills deep. const closed = render(); expect(closed).toMatch(/Add an event · ()?1/); expect(closed).not.toContain("Add Spiral Abyss to dailies"); const open = render( , ); expect(open).toMatch(/aria-label="Add Spiral Abyss to dailies"/); }); test("a goal this version cannot read is said, not shown as none", () => { const unread: StripEvent = { ...login, hasStoredGoal: true }; const html = render( , ); expect(html).toContain("A goal this version can't read"); expect(html).not.toContain("No goal"); }); test("an open row carries the goal control, the sheet and the way out", () => { const html = render( , ); expect(html).toContain("Check-in goal"); expect(html).toContain("Open details"); // Same wording as the sheet, so the reader is told what goes with it. expect(html).toContain("Remove from dailies — removes the goal"); }); test("the switches are offered only for what exists", () => { const html = render(); expect(html).toContain("Daily chores"); expect(html).toContain("Weekly tasks"); expect(html).not.toContain("Monthly tasks"); }); test("with nothing to add, it says where events come from", () => { const html = render( , ); expect(html).not.toContain("to dailies\""); expect(html).toContain("Every running event is already here"); }); }); describe("the strip with an editor", () => { const AT = "2026-10-01T00:00:00.000Z"; const weekly: Task = { id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points", period: { unit: "week", startsOn: 1 }, measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT, }; const status: GoalStatus = { scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 0, met: false, needed: 2100, open: 5, shortfall: false, }; test("offers Edit beside Catch up", () => { const html = render( []} onToggleDay={() => {}} edit={editor()} />, ); expect(html).toContain("Catch up"); expect(html).toMatch(/>Edit { const html = render( []} onToggleDay={() => {}} tasks={[weekly]} taskStatusFor={() => status} onSetAmount={() => {}} amountFor={() => undefined} />, ); const heading = html.indexOf("This week & month"); expect(heading).toBeGreaterThan(-1); expect(html.indexOf("ZZZ")).toBeLessThan(heading); expect(html.indexOf("Activity points")).toBeGreaterThan(heading); }); test("without weekly or monthly things there is no second row", () => { const html = render( []} onToggleDay={() => {}} />, ); expect(html).not.toContain("This week & month"); }); test("an empty strip still offers a way to add an event", () => { const html = render( []} onToggleDay={() => {}} edit={editor({ inStrip: [] })} />, ); expect(html).toMatch(/>Edit { const html = render( []} onToggleDay={() => {}} edit={editor({ inStrip: [], addable: [], shown: { chores: false, weekly: true, monthly: true } })} />, ); expect(html).toBe(""); }); });