feat: the redesigned Event Clock
The new reader app replaces the old UI: Checklist with a rail (focus pills, Next to expire, today's dailies, pinned routines), Timeline in a new and a classic look, Routines, Archive and a Settings area, in a Structured (default) and a Relaxed interface. It follows the approved Round 6 B and Round 7 mockups, corrected screen by screen with the owner. Reader data moves to a v2 store (gacha-tracker:v2:*). Existing v1 data is migrated on first load, a recovery copy is written first, and the v1 keys are never removed, so a rollback finds them unchanged. Redeem codes for Genshin arrive as their own feed. Also: live's urgency ramp, editable per theme; completion and section-complete fireworks; a returning-user tour; the design mockups, specs, plans, reviews and handoff record. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddd9673f86
commit
50c6e91b00
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parseGame8Codes } from "../../src/ingest/codes/game8.ts";
|
||||
test("code attributes are order/quote insensitive and duplicate code rows merge", () => {
|
||||
const row =
|
||||
"<tr><td><input value='AbC&X' class='a-clipboard__textInput' readonly></td><td>Reward & more</td></tr>";
|
||||
const html =
|
||||
"<h3>Latest Redeem Codes</h3><table><tr><th>Redeem Codes</th><th>Rewards</th></tr>" +
|
||||
row +
|
||||
row +
|
||||
"</table><h2>Expired codes</h2>" +
|
||||
row;
|
||||
const codes = parseGame8Codes(html, {
|
||||
game: "genshin",
|
||||
sourceId: "test",
|
||||
sourceUrl: "https://example.com",
|
||||
now: "2026-10-16T12:00:00.000Z",
|
||||
});
|
||||
expect(codes).toHaveLength(1);
|
||||
expect(codes[0]?.code).toBe("AbC&X");
|
||||
expect(codes[0]?.reward).toBe("Reward & more");
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
test("both interface choices, Custom schemes and routine disclosure are available together", () => {
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
[],
|
||||
"2026-10-16T12:00:00.000Z",
|
||||
() => "account:test",
|
||||
);
|
||||
s.prefs.scheme = "custom";
|
||||
const html = render(
|
||||
<AppearancePage state={s} onAction={async () => ({ ok: true })} />,
|
||||
);
|
||||
for (const label of [
|
||||
"Relaxed",
|
||||
"Structured",
|
||||
"Custom",
|
||||
"Dark colours",
|
||||
"Light colours",
|
||||
"Always expanded",
|
||||
"Separate panel",
|
||||
])
|
||||
expect(html).toContain(label);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
appearanceTokens,
|
||||
readableOnSurfaces,
|
||||
contrastRatio,
|
||||
} from "../src/client/state/appearance.ts";
|
||||
test("custom colours retain contrast on both page and surface", () => {
|
||||
const t = appearanceTokens(
|
||||
{
|
||||
scheme: "custom",
|
||||
customPalette: {
|
||||
dark: {
|
||||
ground: "#111111",
|
||||
surface: "#222222",
|
||||
text: "#111111",
|
||||
accent: "#222222",
|
||||
},
|
||||
},
|
||||
},
|
||||
"dark",
|
||||
);
|
||||
expect(contrastRatio(t.ink, t.ground)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(t.ink, t.surface)).toBeGreaterThanOrEqual(4.5);
|
||||
const hue = readableOnSurfaces("#8bcfff", [t.ground, t.surface], 4.5);
|
||||
expect(contrastRatio(hue, t.surface)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
test("palette and theme are independent from interface and density", () => {
|
||||
expect(
|
||||
appearanceTokens(
|
||||
{ scheme: "warm", interface: "structured", density: "compact" },
|
||||
"light",
|
||||
),
|
||||
).toEqual(
|
||||
appearanceTokens(
|
||||
{ scheme: "warm", interface: "relaxed", density: "standard" },
|
||||
"light",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("Structured is the default while a saved Relaxed choice remains distinct", () => {
|
||||
expect(appearanceTokens({}, "dark").ground).toBe("#0e1117");
|
||||
expect(appearanceTokens({ interface: "relaxed" }, "dark").ground).toBe(
|
||||
"#1a1c24",
|
||||
);
|
||||
});
|
||||
|
||||
import { urgencyTokens } from "../src/client/state/appearance.ts";
|
||||
test("urgency follows the live ramp, grey to blue to yellow to red, whatever the accent", () => {
|
||||
const dark = urgencyTokens(appearanceTokens({ accent: "#ff00ff" }, "dark"));
|
||||
expect(dark.near).toBe("#5b8def");
|
||||
expect(dark.soon).toBe("#e8b33c");
|
||||
expect(dark.critical).toBe("#ff4d6a");
|
||||
// Live's grey, nudged only as far as large-text contrast needs.
|
||||
expect(dark.calm).not.toBe(dark.near);
|
||||
const light = urgencyTokens(appearanceTokens({}, "light"));
|
||||
expect(light.near).toBe("#2b62c6");
|
||||
expect(light.soon).toBe("#8a6008");
|
||||
expect(light.critical).toBe("#c81e3c");
|
||||
expect(light.calm).not.toBe(light.near);
|
||||
});
|
||||
|
||||
import { urgencyChoice } from "../src/client/state/appearance.ts";
|
||||
test("the reader's urgency colours apply per theme; unset or invalid steps keep live's", () => {
|
||||
const prefs = {
|
||||
urgencyPalette: {
|
||||
dark: { critical: "#a020f0", soon: "not a colour" },
|
||||
light: { near: "#123456" },
|
||||
},
|
||||
};
|
||||
const dark = urgencyChoice(prefs, "dark");
|
||||
expect(dark.critical).toBe("#a020f0");
|
||||
expect(dark.soon).toBe("#e8b33c");
|
||||
expect(dark.near).toBe("#5b8def");
|
||||
expect(urgencyChoice(prefs, "light").near).toBe("#123456");
|
||||
expect(urgencyChoice({ urgencyPalette: "junk" }, "dark").critical).toBe(
|
||||
"#ff4d6a",
|
||||
);
|
||||
// Applied, a chosen colour is kept when it already reads on the surfaces.
|
||||
expect(
|
||||
urgencyTokens(appearanceTokens(prefs, "dark"), prefs, "dark").critical,
|
||||
).toBe("#a020f0");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { archiveStats } from "../src/client/state/reader/archiveStats.ts";
|
||||
import type { ArchiveRecord } from "../src/client/state/reader/schema.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const r = (outcome: ArchiveRecord["outcome"], id: string): ArchiveRecord => ({
|
||||
accountId: "account:one",
|
||||
event: { ...sampleEvent, id },
|
||||
outcome,
|
||||
outcomeAt: at,
|
||||
resolvedEnd: Date.parse(at),
|
||||
clockRevision: "initial",
|
||||
progress: {},
|
||||
days: ["2026-10-15", "2026-10-16"],
|
||||
amounts: {},
|
||||
});
|
||||
test("ignored outcomes never count as failures; event check-ins are not unique play days", () => {
|
||||
const result = archiveStats(
|
||||
[r("done", "a"), r("ignored", "b"), r("unfinished", "c")],
|
||||
Date.parse(at),
|
||||
);
|
||||
expect(result.overall.rate).toBe(50);
|
||||
expect(result.overall.eligible).toBe(2);
|
||||
expect(result.checkIns).toBe(6);
|
||||
expect(result.shortfalls).toEqual([]);
|
||||
});
|
||||
test("empty archives have no fabricated percentages or progress", () => {
|
||||
const result = archiveStats([], Date.parse(at));
|
||||
expect(result.overall.rate).toBeNull();
|
||||
expect(result.currentRun).toBe(0);
|
||||
expect(result.bestRun).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { ArchivePage } from "../src/client/components/redesign/ArchivePage.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
test("per-game archive retains the description, outcome, Restore and optional history view", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
s.archive.a = {
|
||||
accountId: "account:test",
|
||||
event: { ...sampleEvent, summary: "Saved description" },
|
||||
outcome: "done",
|
||||
outcomeAt: at,
|
||||
resolvedEnd: null,
|
||||
clockRevision: "initial",
|
||||
progress: {},
|
||||
days: [],
|
||||
amounts: {},
|
||||
};
|
||||
const html = render(
|
||||
<ArchivePage
|
||||
state={s}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
for (const label of [
|
||||
"Saved description",
|
||||
"Restore",
|
||||
"History timeline",
|
||||
"Your recorded history",
|
||||
"completed this month",
|
||||
])
|
||||
expect(html).toContain(label);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { runRefresh } from "../scripts/refresh-sources.ts";
|
||||
import { SnapshotStore } from "../src/ingest/snapshots.ts";
|
||||
import { CODE_SOURCES } from "../src/ingest/codes/registry.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
test("event and code sources share host spacing and snapshot gates", async () => {
|
||||
const root = await mkdtemp("/tmp/codes-refresh-");
|
||||
try {
|
||||
const html = await Bun.file(
|
||||
"fixtures/genshin/game8-codes-2026-10-16.html",
|
||||
).text();
|
||||
const slept: number[] = [];
|
||||
const code = CODE_SOURCES[0]!;
|
||||
const summary = await runRefresh({
|
||||
adapters: [
|
||||
{ ...code, id: "event-test", parse: () => [sampleEvent] },
|
||||
code,
|
||||
],
|
||||
store: new SnapshotStore(root),
|
||||
robots: {
|
||||
allows: async () => ({ allowed: true, reason: "test permission" }),
|
||||
},
|
||||
fetchImpl: async () => new Response(html, { status: 200 }),
|
||||
userAgent: "event-clock-test",
|
||||
now: () => new Date("2026-10-16T12:00:00Z"),
|
||||
sleep: async (n) => {
|
||||
slept.push(n);
|
||||
},
|
||||
dryRun: false,
|
||||
only: null,
|
||||
force: false,
|
||||
timeoutMs: 20000,
|
||||
log: () => {},
|
||||
rebuildFeed: null,
|
||||
});
|
||||
expect(summary.changed).toBe(2);
|
||||
expect(slept).toEqual([2000]);
|
||||
expect(summary.outcomes[1]?.eventCount).toBe(10);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
test("the CLI recognises the code source during a request-free dry run", () => {
|
||||
const p = Bun.spawnSync(
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
"scripts/refresh-sources.ts",
|
||||
"--dry-run",
|
||||
"--only",
|
||||
"genshin-game8-codes",
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
);
|
||||
expect(p.exitCode).toBe(0);
|
||||
expect(new TextDecoder().decode(p.stdout)).toContain("genshin-game8-codes");
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { CodesPage } from "../src/client/components/redesign/CodesPage.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { createAccount } from "../src/client/state/reader/accounts.ts";
|
||||
test("redemption controls identify each server and unknown expiry stays unknown", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
s.accounts["account:two"] = createAccount(
|
||||
"genshin",
|
||||
"asia",
|
||||
at,
|
||||
"account:two",
|
||||
);
|
||||
const html = render(
|
||||
<CodesPage
|
||||
game="genshin"
|
||||
state={s}
|
||||
onAction={async () => ({ ok: true })}
|
||||
feed={{
|
||||
schemaVersion: 1,
|
||||
sources: [],
|
||||
codes: [
|
||||
{
|
||||
id: "code",
|
||||
game: "genshin",
|
||||
code: "TEST",
|
||||
reward: null,
|
||||
expiresAt: null,
|
||||
firstObservedAt: at,
|
||||
sourceUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Redeemed · europe");
|
||||
expect(html).toContain("Redeemed · asia");
|
||||
expect(html).toContain("Expiry not announced");
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { buildCodesFeed } from "../src/ingest/codes/build.ts";
|
||||
import { parseGame8Codes } from "../src/ingest/codes/game8.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
ctx = {
|
||||
game: "genshin" as const,
|
||||
sourceId: "genshin-game8-codes",
|
||||
sourceUrl: "https://game8.co/games/Genshin-Impact/archives/304759",
|
||||
now: at,
|
||||
};
|
||||
test("real fixture reads the ten current codes and fences off expired tables", async () => {
|
||||
const html = await Bun.file(
|
||||
"fixtures/genshin/game8-codes-2026-10-16.html",
|
||||
).text();
|
||||
const codes = parseGame8Codes(html, ctx);
|
||||
expect(codes).toHaveLength(10);
|
||||
expect(codes.map((c) => c.code)).toEqual([
|
||||
"NEEIJB764CA3",
|
||||
"6LS3F3LS5K87",
|
||||
"OPERACOLLAB",
|
||||
"VesnaOnPatrol",
|
||||
"EPIC2026",
|
||||
"Y6JYMKV6JKSL",
|
||||
"Y1INABQB6DEX",
|
||||
"2BJ64QRZ7RT8",
|
||||
"PLDXIB886Y1S",
|
||||
"GENSHINGIFT",
|
||||
]);
|
||||
expect(codes.every((c) => c.expiresAt === null)).toBe(true);
|
||||
expect(codes[0]?.reward).toContain("Primogem");
|
||||
expect(codes.some((c) => c.code === "Rekviem")).toBe(false);
|
||||
});
|
||||
test("first-observed time stays fixed across unchanged builds and shape changes reject", () => {
|
||||
const code = {
|
||||
id: '["genshin","TEST"]',
|
||||
game: "genshin" as const,
|
||||
code: "TEST",
|
||||
reward: null,
|
||||
expiresAt: null,
|
||||
sourceUrl: ctx.sourceUrl,
|
||||
};
|
||||
const first = buildCodesFeed(null, [code], at);
|
||||
expect(
|
||||
buildCodesFeed(first, [code], "2026-10-20T12:00:00.000Z").codes[0]
|
||||
?.firstObservedAt,
|
||||
).toBe(at);
|
||||
expect(() => parseGame8Codes("<h1>New design</h1>", ctx)).toThrow();
|
||||
});
|
||||
test("special-program codes are included while their stated expiry date is still ahead", async () => {
|
||||
const html = (
|
||||
await Bun.file("fixtures/genshin/game8-codes-2026-10-16.html").text()
|
||||
).replace("after September 15, 2026", "after November 15, 2026");
|
||||
const codes = parseGame8Codes(html, ctx);
|
||||
expect(codes).toHaveLength(13);
|
||||
expect(codes.some((c) => c.code === "Rekviem")).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { formatInZone } from "../src/client/state/displayTime.ts";
|
||||
test("named timezones use their daylight-saving rules and invalid zones fall back", () => {
|
||||
const winter = formatInZone(
|
||||
"2026-01-01T12:00:00Z",
|
||||
true,
|
||||
"Europe/Copenhagen",
|
||||
);
|
||||
const summer = formatInZone(
|
||||
"2026-07-01T12:00:00Z",
|
||||
true,
|
||||
"Europe/Copenhagen",
|
||||
);
|
||||
expect(winter).toContain("13:00");
|
||||
expect(summer).toContain("14:00");
|
||||
expect(() =>
|
||||
formatInZone("2026-07-01T12:00:00Z", true, "invalid"),
|
||||
).not.toThrow();
|
||||
});
|
||||
import { readerInstant } from "../src/client/state/useCustom.ts";
|
||||
test("custom event entry resolves a chosen zone and rejects nonexistent daylight-saving times", () => {
|
||||
expect(
|
||||
readerInstant("2026-07-01", "12:00", "start", "Europe/Copenhagen"),
|
||||
).toBe("2026-07-01T10:00:00.000Z");
|
||||
expect(
|
||||
readerInstant("2026-03-29", "02:30", "start", "Europe/Copenhagen"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
readerInstant("2026-02-30", "12:00", "start", "Europe/Copenhagen"),
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { GameSettings } from "../src/client/components/redesign/GameSettings.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
test("game settings explains simultaneous server tracking and credits zero-event sources", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const html = render(
|
||||
<GameSettings
|
||||
state={state}
|
||||
initialGame="genshin"
|
||||
sources={[
|
||||
{
|
||||
sourceId: "genshin-extra",
|
||||
game: "genshin",
|
||||
url: "https://example.com/events",
|
||||
lastSuccessAt: null,
|
||||
lastConfirmedAt: null,
|
||||
contentChangedAt: null,
|
||||
eventCount: 0,
|
||||
parsedCount: null,
|
||||
statesNoEvents: false,
|
||||
},
|
||||
]}
|
||||
onAction={async () => ({ ok: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("track both accounts separately");
|
||||
expect(html).toContain("Track another server");
|
||||
expect(html).toContain("Automatic");
|
||||
expect(html).toContain("Reset to default");
|
||||
// Tracking choices save on click; there is no separate apply step.
|
||||
expect(html).not.toContain("Apply tracking choices");
|
||||
// A kind with no built-in routine is explained and still selectable.
|
||||
expect(html).toContain("none built in");
|
||||
expect(html).not.toMatch(/<button[^>]*disabled[^>]*data-st/);
|
||||
});
|
||||
|
||||
import { AppearancePage } from "../src/client/components/redesign/AppearancePage.tsx";
|
||||
test("Appearance orders only the games being tracked, with up and down buttons", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
state = migrateLegacy(
|
||||
{},
|
||||
["genshin", "hsr", "zzz"],
|
||||
at,
|
||||
(() => {
|
||||
let n = 0;
|
||||
return () => `account:${++n}`;
|
||||
})(),
|
||||
);
|
||||
state.prefs.hiddenGames = ["zzz"];
|
||||
const html = render(
|
||||
<AppearancePage state={state} onAction={async () => ({ ok: true })} />,
|
||||
);
|
||||
expect(html).toContain("Game order");
|
||||
expect(html).toContain("Move Genshin Impact down");
|
||||
expect(html).toContain("Move Honkai: Star Rail up");
|
||||
expect(html).not.toContain("Move Zenless Zone Zero");
|
||||
expect(html).not.toContain("Sort A–Z");
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { GachaEvent } from "../../src/shared/schema.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
export const sampleEvent = GachaEvent.parse({
|
||||
id: "genshin:test:2026-10-01",
|
||||
game: "genshin",
|
||||
title: "Test",
|
||||
type: "story",
|
||||
summary: null,
|
||||
startsAt: "2026-10-01T00:00:00.000Z",
|
||||
startPrecision: "day",
|
||||
endsAt: "2026-10-20T00:00:00.000Z",
|
||||
endPrecision: "day",
|
||||
regionScoped: false,
|
||||
regionEnds: null,
|
||||
sourceUrl: "https://example.com",
|
||||
sourceId: "test",
|
||||
confidence: 1,
|
||||
extractionMethod: "parser",
|
||||
version: 1,
|
||||
firstSeenAt: at,
|
||||
updatedAt: at,
|
||||
status: "published",
|
||||
});
|
||||
|
||||
export class MemoryStorage implements Storage {
|
||||
values = new Map<string, string>();
|
||||
fail = "";
|
||||
get length() {
|
||||
return this.values.size;
|
||||
}
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
key(i: number) {
|
||||
return [...this.values.keys()][i] ?? null;
|
||||
}
|
||||
getItem(k: string) {
|
||||
return this.values.get(k) ?? null;
|
||||
}
|
||||
removeItem(k: string) {
|
||||
this.values.delete(k);
|
||||
}
|
||||
setItem(k: string, v: string) {
|
||||
if (k === this.fail) throw new DOMException("Full", "QuotaExceededError");
|
||||
this.values.set(k, v);
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -180,7 +180,7 @@ describe("restorePrefsValue", () => {
|
||||
regionConfirmed: true,
|
||||
onboarded: true,
|
||||
};
|
||||
expect(restorePrefsValue(exported)).toEqual(exported);
|
||||
expect(restorePrefsValue(exported)).toEqual({ ...exported, interface: "structured", density: "standard" });
|
||||
});
|
||||
|
||||
test("falls back to defaults for missing fields from older exports", () => {
|
||||
@@ -225,3 +225,24 @@ describe("restorePrefsValue", () => {
|
||||
expect(restored.view).toBe(defaults().view);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redesign presentation preferences", () => {
|
||||
test("defaults to Structured and Standard without changing the reader's main view", () => {
|
||||
const p = restorePrefsValue({ view: "timeline" });
|
||||
expect(p.interface).toBe("structured");
|
||||
expect(p.density).toBe("standard");
|
||||
expect(p.view).toBe("timeline");
|
||||
});
|
||||
test("keeps Structured and Compact on restore", () => {
|
||||
const p = restorePrefsValue({ interface: "structured", density: "compact" });
|
||||
expect(p.interface).toBe("structured");
|
||||
expect(p.density).toBe("compact");
|
||||
});
|
||||
test("invalid choices fall back to the supported defaults", () => {
|
||||
const p = restorePrefsValue({ interface: "other", density: 42 });
|
||||
expect(p.interface).toBe("structured");
|
||||
expect(p.density).toBe("standard");
|
||||
});
|
||||
});
|
||||
|
||||
test("a saved Relaxed interface survives preference normalization", () => { expect(restorePrefsValue({interface:"relaxed"}).interface).toBe("relaxed"); });
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createAccount,
|
||||
changeAccountRegion,
|
||||
retireAccount,
|
||||
} from "../src/client/state/reader/accounts.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("additional accounts start events-only with fresh placements", () => {
|
||||
const a = createAccount("genshin", "asia", at, "account:second");
|
||||
expect(a.intent).toEqual({
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: false,
|
||||
});
|
||||
expect(a.routines).toEqual({});
|
||||
});
|
||||
test("changing a server preserves account identity and historical clock", () => {
|
||||
const a = createAccount("genshin", "europe", at, "account:one");
|
||||
const b = changeAccountRegion(
|
||||
a,
|
||||
"asia",
|
||||
"2026-10-17T12:00:00.000Z",
|
||||
"clock2",
|
||||
);
|
||||
expect(b.id).toBe(a.id);
|
||||
expect(b.clocks).toHaveLength(2);
|
||||
expect(b.clocks[0]?.region).toBe("europe");
|
||||
expect(retireAccount(b, at).clocks).toEqual(b.clocks);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { applyReaderAliases } from "../src/client/state/reader/aliases.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("retired and merged event ids carry progress and ticks in every account/revision", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.data["account:one"]!.progress.old = { status: "done", at };
|
||||
s.data["account:one"]!.daily.initial = { old: { days: ["2026-10-16"], at } };
|
||||
const next = applyReaderAliases(s, { old: "new" });
|
||||
expect(next.data["account:one"]?.progress.new).toHaveProperty(
|
||||
"status",
|
||||
"done",
|
||||
);
|
||||
expect(next.data["account:one"]?.progress.old).toBeUndefined();
|
||||
expect(next.data["account:one"]?.daily.initial?.new).toHaveProperty("days", [
|
||||
"2026-10-16",
|
||||
]);
|
||||
expect(applyReaderAliases(next, { old: "new" })).toBe(next);
|
||||
});
|
||||
test("aliases carry amount history and durable event snapshots", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.data["account:one"]!.amounts.initial = {
|
||||
old: { "2026-10-16": { value: 7, at } },
|
||||
};
|
||||
const next = applyReaderAliases(s, { old: "new" });
|
||||
expect(next.data["account:one"]?.amounts.initial?.new).toHaveProperty(
|
||||
"2026-10-16",
|
||||
{ value: 7, at },
|
||||
);
|
||||
expect(next.data["account:one"]?.amounts.initial?.old).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import {
|
||||
captureSeen,
|
||||
reconcileArchive,
|
||||
archiveOutcome,
|
||||
restoreArchive,
|
||||
} from "../src/client/state/reader/archive.ts";
|
||||
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
import { pairKey } from "../src/client/state/reader/schema.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("archive saves metadata and outcome immediately; restore preserves the event snapshot", () => {
|
||||
let s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s = captureSeen(s, readerEventRows(s, [sampleEvent], Date.parse(at)), at);
|
||||
s = archiveOutcome(s, "account:one", sampleEvent.id, "done", at);
|
||||
expect(s.archive[pairKey("account:one", sampleEvent.id)]?.event.title).toBe(
|
||||
"Test",
|
||||
);
|
||||
expect(s.data["account:one"]?.progress[sampleEvent.id]).toHaveProperty(
|
||||
"status",
|
||||
"done",
|
||||
);
|
||||
s = restoreArchive(s, "account:one", sampleEvent.id);
|
||||
expect(s.archive).toEqual({});
|
||||
expect(s.seenEvents[pairKey("account:one", sampleEvent.id)]).toBeDefined();
|
||||
});
|
||||
test("missing source rows are not proof of expiry, and unknown end remains unresolved", () => {
|
||||
let s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s = captureSeen(
|
||||
s,
|
||||
readerEventRows(
|
||||
s,
|
||||
[
|
||||
sampleEvent,
|
||||
{
|
||||
...sampleEvent,
|
||||
id: "genshin:unknown:2026-10-01",
|
||||
endsAt: null,
|
||||
endPrecision: "unknown",
|
||||
},
|
||||
],
|
||||
Date.parse(at),
|
||||
),
|
||||
at,
|
||||
);
|
||||
s = reconcileArchive(s, [], Date.parse(at));
|
||||
expect(s.archive).toEqual({});
|
||||
s = reconcileArchive(s, [], Date.parse("2026-11-01T12:00:00Z"));
|
||||
expect(Object.values(s.archive)).toHaveLength(1);
|
||||
expect(Object.values(s.archive)[0]?.outcome).toBe("unfinished");
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
backupTier,
|
||||
retainBackups,
|
||||
} from "../src/client/state/reader/backups.ts";
|
||||
|
||||
const now = Date.parse("2026-10-17T12:00:00.000Z");
|
||||
const daysAgo = (d: number) => new Date(now - d * 86_400_000).toISOString();
|
||||
|
||||
test("a snapshot's tier matches the window that retains it", () => {
|
||||
expect(backupTier(daysAgo(0), now)).toBe("daily");
|
||||
expect(backupTier(daysAgo(6), now)).toBe("daily");
|
||||
expect(backupTier(daysAgo(8), now)).toBe("weekly");
|
||||
expect(backupTier(daysAgo(33), now)).toBe("weekly");
|
||||
expect(backupTier(daysAgo(40), now)).toBe("monthly");
|
||||
// Everything retained falls in one of the three.
|
||||
const kept = retainBackups(
|
||||
Array.from({ length: 200 }, (_, i) => ({
|
||||
id: String(i),
|
||||
at: daysAgo(i),
|
||||
payload: {},
|
||||
})),
|
||||
now,
|
||||
);
|
||||
const tiers = kept.map((s) => backupTier(s.at, now));
|
||||
expect(tiers.filter((t) => t === "daily").length).toBe(7);
|
||||
expect(tiers.filter((t) => t === "weekly").length).toBe(4);
|
||||
expect(tiers.filter((t) => t === "monthly").length).toBe(6);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
retainBackups,
|
||||
type BackupSnapshot,
|
||||
} from "../src/client/state/reader/backups.ts";
|
||||
const now = Date.parse("2026-10-16T12:00:00Z"),
|
||||
day = 86400000;
|
||||
test("400 days fold to seven daily, four weekly and six monthly real snapshots", () => {
|
||||
const values: BackupSnapshot[] = Array.from({ length: 400 }, (_, i) => ({
|
||||
id: String(i),
|
||||
at: new Date(now - i * day).toISOString(),
|
||||
payload: { day: i },
|
||||
}));
|
||||
const kept = retainBackups(values, now);
|
||||
expect(kept).toHaveLength(17);
|
||||
expect(kept.slice(0, 7).map((v) => v.id)).toEqual([
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
]);
|
||||
expect(new Set(kept.map((v) => v.id)).size).toBe(17);
|
||||
});
|
||||
test("latest per bucket wins, future and ancient snapshots are excluded", () => {
|
||||
const values = [
|
||||
{ id: "old", at: "2026-10-16T01:00:00Z", payload: {} },
|
||||
{ id: "new", at: "2026-10-16T10:00:00Z", payload: {} },
|
||||
{ id: "future", at: "2027-01-01T00:00:00Z", payload: {} },
|
||||
{ id: "ancient", at: "2024-01-01T00:00:00Z", payload: {} },
|
||||
];
|
||||
expect(retainBackups(values, now).map((v) => v.id)).toEqual(["new"]);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import {
|
||||
appendClockRevision,
|
||||
resolveReaderClock,
|
||||
readerDayKey,
|
||||
readerClockFor,
|
||||
} from "../src/shared/readerClock.ts";
|
||||
import { clockFor } from "../src/shared/time.ts";
|
||||
import { dayKey } from "../src/shared/daily.ts";
|
||||
import { GAMES } from "../src/shared/games.ts";
|
||||
import { GachaEvent } from "../src/shared/schema.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
now = Date.parse(at);
|
||||
const account = () =>
|
||||
Object.values(
|
||||
migrateLegacy({}, ["genshin"], at, () => "account:one").accounts,
|
||||
)[0]!;
|
||||
test("automatic matches every game and server's existing day-key clock", () => {
|
||||
for (const game of Object.keys(GAMES))
|
||||
for (const region of ["asia", "america", "europe"] as const) {
|
||||
const a = Object.values(
|
||||
migrateLegacy(
|
||||
{ "gacha-tracker:v1:prefs": JSON.stringify({ region }) },
|
||||
[game],
|
||||
at,
|
||||
() => "account:one",
|
||||
).accounts,
|
||||
)[0]!;
|
||||
expect(readerDayKey(now, resolveReaderClock(a, now))).toBe(
|
||||
dayKey(now, region, game),
|
||||
);
|
||||
}
|
||||
});
|
||||
test("reset override/revert retains revisions, including a backwards current day", () => {
|
||||
const a = account(),
|
||||
t = "2026-10-17T03:10:00.000Z";
|
||||
const custom = appendClockRevision(
|
||||
a,
|
||||
{ hour: 5, minute: 30 },
|
||||
"europe",
|
||||
t,
|
||||
"custom",
|
||||
);
|
||||
expect(
|
||||
readerDayKey(Date.parse(t), resolveReaderClock(a, Date.parse(t))),
|
||||
).toBe("2026-10-17");
|
||||
expect(
|
||||
readerDayKey(Date.parse(t), resolveReaderClock(custom, Date.parse(t))),
|
||||
).toBe("2026-10-16");
|
||||
expect(resolveReaderClock(custom, Date.parse(t) - 1).revision).toBe(
|
||||
"initial",
|
||||
);
|
||||
const reverted = appendClockRevision(
|
||||
custom,
|
||||
null,
|
||||
"europe",
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
"reverted",
|
||||
);
|
||||
expect(reverted.clocks).toHaveLength(3);
|
||||
expect(
|
||||
resolveReaderClock(reverted, Date.parse("2026-10-18T13:00:00.000Z"))
|
||||
.resetHour,
|
||||
).toBe(4);
|
||||
});
|
||||
const event = () =>
|
||||
GachaEvent.parse({
|
||||
id: "genshin:test:2026-10-01",
|
||||
game: "genshin",
|
||||
title: "Test",
|
||||
type: "story",
|
||||
summary: null,
|
||||
startsAt: "2026-10-01T00:00:00.000Z",
|
||||
startPrecision: "day",
|
||||
endsAt: "2026-10-20T00:00:00.000Z",
|
||||
endPrecision: "day",
|
||||
sourceUrl: "https://example.com",
|
||||
regionScoped: true,
|
||||
regionEnds: {},
|
||||
sourceId: "test",
|
||||
confidence: 1,
|
||||
extractionMethod: "parser",
|
||||
version: 1,
|
||||
firstSeenAt: at,
|
||||
updatedAt: at,
|
||||
status: "published",
|
||||
});
|
||||
test("only reset-based boundaries move; exact regional ends remain authoritative", () => {
|
||||
const a = account(),
|
||||
custom = appendClockRevision(
|
||||
a,
|
||||
{ hour: 5, minute: 30 },
|
||||
"europe",
|
||||
at,
|
||||
"custom",
|
||||
);
|
||||
const e = event();
|
||||
expect(
|
||||
readerClockFor(e, custom, now).endsMs! - clockFor(e, "europe", now).endsMs!,
|
||||
).toBe(90 * 60000);
|
||||
const exact = { ...e, regionEnds: { europe: "2026-10-20T18:00:00.000Z" } };
|
||||
expect(readerClockFor(exact, custom, now).endsMs).toBe(
|
||||
Date.parse(exact.regionEnds.europe),
|
||||
);
|
||||
expect(
|
||||
readerClockFor({ ...e, endsAt: null, endPrecision: "unknown" }, custom, now)
|
||||
.endsMs,
|
||||
).toBeNull();
|
||||
});
|
||||
test("a reader-entered day-precision instant is never moved by a reset override", () => {
|
||||
const a = appendClockRevision(
|
||||
account(),
|
||||
{ hour: 5, minute: 30 },
|
||||
"europe",
|
||||
at,
|
||||
"custom",
|
||||
);
|
||||
const e = { ...event(), extractionMethod: "manual" as const };
|
||||
expect(readerClockFor(e, a, now).endsMs).toBe(Date.parse(e.endsAt!));
|
||||
});
|
||||
import { refreshAutomaticClocks } from "../src/client/state/reader/clock.ts";
|
||||
test("automatic metadata corrections create a revision instead of rewriting history", () => {
|
||||
const a = account();
|
||||
a.clocks[0]!.automaticBase.hour = 3;
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
state.accounts[a.id] = a;
|
||||
const next = refreshAutomaticClocks(state, at);
|
||||
expect(next.accounts[a.id]?.clocks).toHaveLength(2);
|
||||
expect(next.accounts[a.id]?.clocks[0]?.automaticBase.hour).toBe(3);
|
||||
expect(resolveReaderClock(next.accounts[a.id]!, now).resetHour).toBe(4);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { newCodes } from "../src/client/state/reader/codes.ts";
|
||||
import { reduceReader } from "../src/client/state/reader/reducer.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("viewing codes clears newness independently from redemption on two accounts", () => {
|
||||
const feed = {
|
||||
schemaVersion: 1 as const,
|
||||
sources: [],
|
||||
codes: [
|
||||
{
|
||||
id: "code",
|
||||
game: "genshin" as const,
|
||||
code: "TEST",
|
||||
reward: null,
|
||||
expiresAt: null,
|
||||
firstObservedAt: at,
|
||||
sourceUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
};
|
||||
let state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
expect(newCodes(feed, [], "genshin")).toHaveLength(1);
|
||||
state = reduceReader(state, {
|
||||
type: "seen",
|
||||
kind: "codeSeen",
|
||||
id: "genshin",
|
||||
ids: ["code"],
|
||||
at,
|
||||
opId: "seen",
|
||||
});
|
||||
expect(newCodes(feed, state.codeSeen.genshin!, "genshin")).toHaveLength(0);
|
||||
expect(state.codeRedeemed).toEqual({});
|
||||
state = reduceReader(state, {
|
||||
type: "redeem",
|
||||
accountId: "account:one",
|
||||
codeId: "code",
|
||||
redeemed: true,
|
||||
at,
|
||||
opId: "redeem",
|
||||
});
|
||||
expect(state.codeRedeemed["account:one"]?.code).toBe(at);
|
||||
expect(state.codeRedeemed["account:two"]).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { createAccount } from "../src/client/state/reader/accounts.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
|
||||
test("events resolve on each account's server without changing domain identities", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.accounts["account:two"] = createAccount(
|
||||
"genshin",
|
||||
"asia",
|
||||
at,
|
||||
"account:two",
|
||||
);
|
||||
s.data["account:two"] = { progress: {}, ignored: {}, daily: {}, amounts: {} };
|
||||
const rows = readerEventRows(s, [sampleEvent], Date.parse(at));
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]?.event.id).toBe(sampleEvent.id);
|
||||
expect(new Set(rows.map((r) => r.clock.endsMs)).size).toBe(2);
|
||||
expect(new Set(rows.map((r) => r.accountId)).size).toBe(2);
|
||||
});
|
||||
test("missing future and unknown-end seen events remain accessible", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.seenEvents.a = {
|
||||
accountId: "account:one",
|
||||
event: { ...sampleEvent, endsAt: null, endPrecision: "unknown" },
|
||||
firstSeenAt: at,
|
||||
lastSeenAt: at,
|
||||
clockRevision: "initial",
|
||||
};
|
||||
expect(readerEventRows(s, [], Date.parse(at))[0]?.clock.endsMs).toBeNull();
|
||||
});
|
||||
test("deleted reader events are not resurrected from their seen snapshots", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.seenEvents.a = {
|
||||
accountId: "account:one",
|
||||
event: { ...sampleEvent, id: "myevent:deleted", sourceUrl: null },
|
||||
firstSeenAt: at,
|
||||
lastSeenAt: at,
|
||||
clockRevision: "initial",
|
||||
};
|
||||
expect(readerEventRows(s, [], Date.parse(at))).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import {
|
||||
buildReaderExport,
|
||||
parseReaderExport,
|
||||
} from "../src/client/state/reader/export.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("full reader export round-trips unknown fields without including backup history", () => {
|
||||
const reader = {
|
||||
...migrateLegacy({}, ["genshin"], at, () => "account:one"),
|
||||
future: { keep: true },
|
||||
};
|
||||
const value = buildReaderExport(
|
||||
reader,
|
||||
{
|
||||
"gacha-tracker:v1:tasks": '{"unreadable":true}',
|
||||
"gacha-tracker:v2:backups": "do-not-recurse",
|
||||
},
|
||||
at,
|
||||
);
|
||||
expect(parseReaderExport(value)).toEqual(reader);
|
||||
expect(JSON.stringify(value)).not.toContain("do-not-recurse");
|
||||
});
|
||||
test("unknown versions and invalid known fields cannot be restored", () => {
|
||||
const value = buildReaderExport(
|
||||
migrateLegacy({}, [], at, () => "account:one"),
|
||||
{},
|
||||
at,
|
||||
);
|
||||
expect(parseReaderExport({ ...value, version: 999 })).toBeNull();
|
||||
expect(
|
||||
parseReaderExport({ ...value, reader: { version: 2, accounts: [] } }),
|
||||
).toBeNull();
|
||||
});
|
||||
test("existing version-one exports remain convertible", () => {
|
||||
const reader = parseReaderExport({
|
||||
format: "gacha-tracker-export",
|
||||
version: 1,
|
||||
exportedAt: at,
|
||||
prefs: { region: "asia" },
|
||||
progress: { "genshin:a:2026-01-01": { status: "done", at } },
|
||||
daily: {},
|
||||
ignored: {},
|
||||
customGames: {},
|
||||
customEvents: {},
|
||||
});
|
||||
expect(reader?.version).toBe(2);
|
||||
expect(Object.values(reader!.accounts)[0]?.region).toBe("asia");
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mintAccountId } from "../src/client/state/reader/ids.ts";
|
||||
import { actionMeta } from "../src/client/state/reader/actions.ts";
|
||||
import { initializeReader } from "../src/client/state/reader/store.ts";
|
||||
import { saveBackup, readBackups } from "../src/client/state/reader/backups.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
import { MemoryStorage } from "./helpers/reader.ts";
|
||||
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
function withoutRandomUUID(run: () => void) {
|
||||
const before = Object.getOwnPropertyDescriptor(crypto, "randomUUID");
|
||||
Object.defineProperty(crypto, "randomUUID", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (before) Object.defineProperty(crypto, "randomUUID", before);
|
||||
else Reflect.deleteProperty(crypto, "randomUUID");
|
||||
}
|
||||
}
|
||||
|
||||
test("HTTP first-visit migration succeeds without randomUUID and retains legacy bytes", () => {
|
||||
withoutRandomUUID(() => {
|
||||
const storage = new MemoryStorage();
|
||||
const raw = JSON.stringify({
|
||||
"genshin:example:2026-10-01": { note: "Keep my progress", at },
|
||||
});
|
||||
storage.setItem("gacha-tracker:v1:progress", raw);
|
||||
const result = initializeReader(storage, ["genshin"], at, mintAccountId);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(storage.getItem("gacha-tracker:v1:progress")).toBe(raw);
|
||||
expect(storage.getItem(READER_KEYS.reader)).not.toBeNull();
|
||||
if (result.ok)
|
||||
expect(
|
||||
Object.values(result.state.data)[0]!.progress[
|
||||
"genshin:example:2026-10-01"
|
||||
],
|
||||
).toHaveProperty("note", "Keep my progress");
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP edits receive distinct operation IDs without randomUUID", () => {
|
||||
withoutRandomUUID(() => {
|
||||
const first = actionMeta(),
|
||||
second = actionMeta();
|
||||
expect(first.opId).toMatch(/^[a-zA-Z0-9-]+$/);
|
||||
expect(first.opId).not.toBe(second.opId);
|
||||
});
|
||||
});
|
||||
|
||||
test("HTTP local backups save normally without randomUUID", () => {
|
||||
withoutRandomUUID(() => {
|
||||
const storage = new MemoryStorage();
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
expect(saveBackup(storage, state, at)).toEqual({ ok: true });
|
||||
expect(readBackups(storage)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { resolveReaderClock } from "../src/shared/readerClock.ts";
|
||||
import { expect, test } from "bun:test";
|
||||
import { mergeReaderData } from "../src/client/state/reader/import.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("progress import merges by game/server and preserves local preferences and unknown fields", () => {
|
||||
const a = migrateLegacy({}, ["genshin"], at, () => "account:local"),
|
||||
b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
|
||||
a.prefs.interface = "structured";
|
||||
a.data["account:local"]!.progress.event = {
|
||||
note: "mine",
|
||||
at: "2026-10-15T12:00:00.000Z",
|
||||
future: 42,
|
||||
};
|
||||
b.data["account:imported"]!.progress.event = { status: "done", at };
|
||||
const merged = mergeReaderData(a, b);
|
||||
expect(merged.prefs.interface).toBe("structured");
|
||||
expect(Object.keys(merged.accounts)).toEqual(["account:local"]);
|
||||
expect(merged.data["account:local"]?.progress.event).toMatchObject({
|
||||
status: "done",
|
||||
note: "mine",
|
||||
future: 42,
|
||||
});
|
||||
});
|
||||
test("import allocates distinct revision ids and prefers updated custom edits", () => {
|
||||
const a = migrateLegacy({}, ["genshin"], at, () => "account:local");
|
||||
const b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
|
||||
b.accounts["account:imported"]!.clocks.push(
|
||||
{
|
||||
...b.accounts["account:imported"]!.clocks[0]!,
|
||||
id: "initial-import",
|
||||
reset: { hour: 5, minute: 0 },
|
||||
},
|
||||
{
|
||||
...b.accounts["account:imported"]!.clocks[0]!,
|
||||
id: "initial",
|
||||
reset: { hour: 6, minute: 0 },
|
||||
},
|
||||
);
|
||||
a.tasks.task = { at, updatedAt: "2026-10-16T13:00:00.000Z", title: "older" };
|
||||
b.tasks.task = { at, updatedAt: "2026-10-16T14:00:00.000Z", title: "newer" };
|
||||
const merged = mergeReaderData(a, b);
|
||||
const ids = merged.accounts["account:local"]!.clocks.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(merged.tasks.task).toMatchObject({ title: "newer" });
|
||||
});
|
||||
test("progress import retains imported history without replacing the local reset", () => {
|
||||
const a = migrateLegacy({}, ["genshin"], at, () => "account:local");
|
||||
const b = migrateLegacy({}, ["genshin"], at, () => "account:imported");
|
||||
b.accounts["account:imported"]!.clocks.push({
|
||||
...b.accounts["account:imported"]!.clocks[0]!,
|
||||
id: "custom",
|
||||
effectiveAt: at,
|
||||
reset: { hour: 9, minute: 0 },
|
||||
});
|
||||
const merged = mergeReaderData(a, b);
|
||||
expect(
|
||||
resolveReaderClock(merged.accounts["account:local"]!, Date.parse(at))
|
||||
.resetHour,
|
||||
).toBe(4);
|
||||
expect(
|
||||
merged.accounts["account:local"]!.clocks.some((c) => c.id === "custom"),
|
||||
).toBe(true);
|
||||
});
|
||||
test("repeated progress imports keep clocks and opaque extension history idempotent", () => {
|
||||
const a = migrateLegacy({}, ["genshin"], at, () => "account:local"),
|
||||
b = migrateLegacy({}, ["genshin"], at, () => "account:remote");
|
||||
b.accounts["account:remote"]!.clocks.push({
|
||||
...b.accounts["account:remote"]!.clocks[0]!,
|
||||
id: "override",
|
||||
reset: { hour: 9, minute: 0 },
|
||||
});
|
||||
b.accounts["account:remote"]!.futureAccount = { opaque: 42 };
|
||||
const once = mergeReaderData(a, b),
|
||||
twice = mergeReaderData(once, b);
|
||||
expect(twice.accounts["account:local"]!.clocks.length).toBe(
|
||||
once.accounts["account:local"]!.clocks.length,
|
||||
);
|
||||
const third = mergeReaderData(
|
||||
migrateLegacy({}, ["genshin"], at, () => "account:third"),
|
||||
once,
|
||||
);
|
||||
expect(JSON.stringify(third.importedExtensions)).toContain('"opaque":42');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
initializeReader,
|
||||
createReaderStore,
|
||||
} from "../src/client/state/reader/store.ts";
|
||||
import { MemoryStorage } from "./helpers/reader.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("a returning older tab's newer edit is carried to the original migrated account", async () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(
|
||||
"gacha-tracker:v1:progress",
|
||||
JSON.stringify({ "genshin:a:2026-01-01": { status: "doing", at } }),
|
||||
);
|
||||
const first = initializeReader(s, ["genshin"], at, () => "account:original");
|
||||
expect(first.ok).toBe(true);
|
||||
s.setItem(
|
||||
"gacha-tracker:v1:progress",
|
||||
JSON.stringify({
|
||||
"genshin:a:2026-01-01": {
|
||||
status: "done",
|
||||
note: "From old tab",
|
||||
at: "2026-10-17T12:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const again = initializeReader(
|
||||
s,
|
||||
["genshin"],
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
() => {
|
||||
throw Error("Must not remint");
|
||||
},
|
||||
);
|
||||
expect(again.ok).toBe(true);
|
||||
if (again.ok)
|
||||
expect(
|
||||
again.state.data["account:original"]?.progress["genshin:a:2026-01-01"],
|
||||
).toHaveProperty("note", "From old tab");
|
||||
});
|
||||
test("a removed v2 field is not resurrected by an older legacy copy", async () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(
|
||||
"gacha-tracker:v1:progress",
|
||||
JSON.stringify({ "genshin:a:2026-01-01": { note: "old", at } }),
|
||||
);
|
||||
const first = initializeReader(s, ["genshin"], at, () => "account:original");
|
||||
if (!first.ok) throw Error("init");
|
||||
const store = createReaderStore(s, first.state);
|
||||
await store.dispatch({
|
||||
type: "progress",
|
||||
accountId: "account:original",
|
||||
itemId: "genshin:a:2026-01-01",
|
||||
patch: { note: null },
|
||||
at: "2026-10-18T12:00:00.000Z",
|
||||
opId: "clear",
|
||||
});
|
||||
s.setItem("gacha-tracker:v1:prefs", JSON.stringify({ region: "asia" }));
|
||||
initializeReader(s, ["genshin"], "2026-10-19T12:00:00.000Z", () => {
|
||||
throw Error("Must not remint");
|
||||
});
|
||||
expect(
|
||||
JSON.parse(s.getItem(READER_KEYS.reader)!).data["account:original"]
|
||||
.progress["genshin:a:2026-01-01"].note,
|
||||
).toBeUndefined();
|
||||
});
|
||||
test("old-tab removals clear migrated ticks and ignores without clearing newer v2 edits", () => {
|
||||
const s = new MemoryStorage(),
|
||||
id = "genshin:a:2026-01-01";
|
||||
s.setItem(
|
||||
"gacha-tracker:v1:daily",
|
||||
JSON.stringify({ [id]: { days: ["2026-10-16"], at } }),
|
||||
);
|
||||
s.setItem("gacha-tracker:v1:ignored", JSON.stringify({ [id]: { at } }));
|
||||
initializeReader(s, ["genshin"], at, () => "account:original");
|
||||
s.setItem("gacha-tracker:v1:daily", "{}");
|
||||
s.setItem("gacha-tracker:v1:ignored", "{}");
|
||||
const result = initializeReader(
|
||||
s,
|
||||
["genshin"],
|
||||
"2026-10-18T12:00:00.000Z",
|
||||
() => {
|
||||
throw Error("remint");
|
||||
},
|
||||
);
|
||||
if (!result.ok) throw Error(result.reason);
|
||||
expect(
|
||||
result.state.data["account:original"]!.daily.initial?.[id],
|
||||
).toHaveProperty("days", []);
|
||||
expect(result.state.data["account:original"]!.ignored[id]).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
deleteManualBackup,
|
||||
MANUAL_BACKUP_BYTES,
|
||||
MANUAL_BACKUP_LIMIT,
|
||||
readBackups,
|
||||
readManualBackups,
|
||||
saveBackup,
|
||||
saveManualBackup,
|
||||
} from "../src/client/state/reader/backups.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
|
||||
const memory = (): Storage => {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
getItem: (k) => m.get(k) ?? null,
|
||||
setItem: (k, v) => void m.set(k, String(v)),
|
||||
removeItem: (k) => void m.delete(k),
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
const at = "2026-10-17T12:00:00.000Z";
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
|
||||
test("Back up now keeps its own list and never replaces the day's automatic snapshot", () => {
|
||||
const storage = memory();
|
||||
saveBackup(storage, state, at);
|
||||
const automatic = readBackups(storage);
|
||||
expect(saveManualBackup(storage, state, "2026-10-17T13:00:00.000Z").ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(saveManualBackup(storage, state, "2026-10-17T14:00:00.000Z").ok).toBe(
|
||||
true,
|
||||
);
|
||||
expect(readBackups(storage)).toEqual(automatic);
|
||||
const manual = readManualBackups(storage);
|
||||
expect(manual.map((b) => b.at)).toEqual([
|
||||
"2026-10-17T14:00:00.000Z",
|
||||
"2026-10-17T13:00:00.000Z",
|
||||
]);
|
||||
expect(storage.getItem(READER_KEYS.manualBackups)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("a manual backup can be deleted, and the list has a cap", () => {
|
||||
const storage = memory();
|
||||
saveManualBackup(storage, state, at);
|
||||
const [only] = readManualBackups(storage);
|
||||
expect(deleteManualBackup(storage, only!.id).ok).toBe(true);
|
||||
expect(readManualBackups(storage)).toEqual([]);
|
||||
for (let i = 0; i < MANUAL_BACKUP_LIMIT; i++)
|
||||
expect(
|
||||
saveManualBackup(
|
||||
storage,
|
||||
state,
|
||||
new Date(Date.parse(at) + i).toISOString(),
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const full = saveManualBackup(storage, state, "2026-10-18T00:00:00.000Z");
|
||||
expect(full).toEqual({ ok: false, reason: "full" });
|
||||
expect(readManualBackups(storage)).toHaveLength(MANUAL_BACKUP_LIMIT);
|
||||
});
|
||||
|
||||
test("one unreadable entry does not discard the other manual backups", () => {
|
||||
const storage = memory();
|
||||
saveManualBackup(storage, state, at);
|
||||
const list = JSON.parse(storage.getItem(READER_KEYS.manualBackups)!);
|
||||
storage.setItem(
|
||||
READER_KEYS.manualBackups,
|
||||
JSON.stringify([...list, { id: 1, at: "not a date" }]),
|
||||
);
|
||||
expect(readManualBackups(storage)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("the list refuses a copy that would push it past its size budget", () => {
|
||||
const storage = memory();
|
||||
const big = {
|
||||
...state,
|
||||
prefs: { ...state.prefs, filler: "x".repeat(MANUAL_BACKUP_BYTES) },
|
||||
};
|
||||
expect(saveManualBackup(storage, big, at)).toEqual({
|
||||
ok: false,
|
||||
reason: "full",
|
||||
});
|
||||
expect(readManualBackups(storage)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { ReaderStateSchema } from "../src/client/state/reader/schema.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const raw = {
|
||||
"gacha-tracker:v1:prefs": JSON.stringify({
|
||||
region: "europe",
|
||||
view: "timeline",
|
||||
theme: "light",
|
||||
hiddenGames: ["hsr"],
|
||||
gameOrder: ["genshin", "hsr"],
|
||||
showRoutines: true,
|
||||
routinesHidden: ["routine:genshin:weekly-bosses"],
|
||||
}),
|
||||
"gacha-tracker:v1:progress": JSON.stringify({
|
||||
"genshin:test:2026-10-01": { status: "done", at, goal: { future: true } },
|
||||
"foreign-id": { note: "keep", at },
|
||||
}),
|
||||
"gacha-tracker:v1:daily": JSON.stringify({
|
||||
"dailies:genshin": { days: ["2026-10-15"], at },
|
||||
}),
|
||||
"gacha-tracker:v1:tasks": JSON.stringify({
|
||||
"mytask:abcdef": {
|
||||
id: "mytask:abcdef",
|
||||
game: "genshin",
|
||||
title: "Mine",
|
||||
period: { unit: "week", startsOn: 1 },
|
||||
measure: { kind: "days", required: 1 },
|
||||
at,
|
||||
updatedAt: at,
|
||||
future: 42,
|
||||
},
|
||||
}),
|
||||
"gacha-tracker:v1:amounts": JSON.stringify({
|
||||
"mytask:abcdef": { "2026-10-12": { value: 7, at } },
|
||||
}),
|
||||
};
|
||||
test("migration preserves view, region, game choices and exact history keys", () => {
|
||||
let n = 0;
|
||||
const state = migrateLegacy(
|
||||
raw,
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
() => `account:test${++n}`,
|
||||
);
|
||||
expect(ReaderStateSchema.safeParse(state).success).toBe(true);
|
||||
expect(state.prefs.view).toBe("timeline");
|
||||
expect(state.prefs.hiddenGames).toEqual(["hsr"]);
|
||||
const account = Object.values(state.accounts).find(
|
||||
(a) => a.game === "genshin",
|
||||
)!;
|
||||
expect(account.region).toBe("europe");
|
||||
const data = state.data[account.id]!;
|
||||
expect(data.progress["genshin:test:2026-10-01"]).toEqual({
|
||||
status: "done",
|
||||
at,
|
||||
goal: { future: true },
|
||||
});
|
||||
expect(data.daily[account.clocks[0]!.id]?.["dailies:genshin"]).toEqual({
|
||||
days: ["2026-10-15"],
|
||||
at,
|
||||
});
|
||||
expect(data.amounts[account.clocks[0]!.id]?.["mytask:abcdef"]).toEqual({
|
||||
"2026-10-12": { value: 7, at },
|
||||
});
|
||||
expect(account.routines["routine:genshin:weekly-bosses"]?.followed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(state.tasks["mytask:abcdef"]).toHaveProperty("future", 42);
|
||||
expect(raw["gacha-tracker:v1:progress"]).toContain("foreign-id");
|
||||
});
|
||||
test("completion-only installs and games absent from feed retain their state", () => {
|
||||
const state = migrateLegacy(
|
||||
{
|
||||
"gacha-tracker:v1:completions": JSON.stringify({
|
||||
"zzz:old:2026-01-01": { at },
|
||||
}),
|
||||
},
|
||||
[],
|
||||
at,
|
||||
() => "account:old",
|
||||
);
|
||||
const account = Object.values(state.accounts)[0]!;
|
||||
expect(account.game).toBe("zzz");
|
||||
expect(state.data[account.id]?.progress["zzz:old:2026-01-01"]).toEqual({
|
||||
status: "done",
|
||||
at,
|
||||
});
|
||||
});
|
||||
test("malformed blobs cannot hide valid state in other legacy keys", () => {
|
||||
const state = migrateLegacy(
|
||||
{ ...raw, "gacha-tracker:v1:tasks": "broken" },
|
||||
["genshin"],
|
||||
at,
|
||||
() => "account:one",
|
||||
);
|
||||
expect(state.tasks).toEqual({});
|
||||
expect(state.data["account:one"]?.progress).toHaveProperty(
|
||||
"genshin:test:2026-10-01",
|
||||
);
|
||||
});
|
||||
test("new catalogue games remain switched off for a returning reader", () => {
|
||||
let n = 0;
|
||||
const s = migrateLegacy(
|
||||
{
|
||||
"gacha-tracker:v1:prefs": JSON.stringify({
|
||||
knownGames: ["genshin"],
|
||||
hiddenGames: [],
|
||||
onboarded: true,
|
||||
}),
|
||||
},
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
() => `account:new${++n}`,
|
||||
);
|
||||
expect(s.prefs.hiddenGames).toEqual(["hsr"]);
|
||||
expect(
|
||||
Object.values(s.accounts).find((a) => a.game === "hsr")?.intent.events,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { setupReader } from "../src/client/state/reader/onboarding.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("new-reader setup is events-only and remembers unsupported cycle intent", () => {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const next = setupReader(
|
||||
{
|
||||
region: "asia",
|
||||
interface: "relaxed",
|
||||
theme: "dark",
|
||||
scheme: "slate",
|
||||
timezone: null,
|
||||
games: ["genshin"],
|
||||
tracking: {
|
||||
genshin: {
|
||||
events: true,
|
||||
dailies: false,
|
||||
weeklyMonthly: false,
|
||||
cycles: true,
|
||||
},
|
||||
},
|
||||
showUpcoming: false,
|
||||
view: "timeline",
|
||||
},
|
||||
state,
|
||||
at,
|
||||
() => "account:two",
|
||||
);
|
||||
const a = next.accounts["account:one"]!;
|
||||
expect(a.region).toBe("asia");
|
||||
expect(a.intent.cycles).toBe(true);
|
||||
expect(a.routines["dailies:genshin"]?.followed).toBe(false);
|
||||
expect(next.prefs.onboarded).toBe(true);
|
||||
expect(next.prefs.view).toBe("timeline");
|
||||
expect(next.prefs.showUpcoming).toBe(false);
|
||||
});
|
||||
|
||||
test("the timeline design is saved only when the reader starts on Timeline", () => {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const draft = {
|
||||
region: "europe" as const,
|
||||
interface: "structured" as const,
|
||||
theme: "dark" as const,
|
||||
scheme: "slate",
|
||||
timezone: null,
|
||||
games: ["genshin"],
|
||||
tracking: {},
|
||||
showUpcoming: false,
|
||||
view: "timeline" as const,
|
||||
timelineLook: "classic" as const,
|
||||
};
|
||||
const mint = () => "account:two";
|
||||
expect(setupReader(draft, state, at, mint).prefs.timelineLook).toBe(
|
||||
"classic",
|
||||
);
|
||||
expect(
|
||||
setupReader({ ...draft, view: "soon" }, state, at, mint).prefs.timelineLook,
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
prepareRestore,
|
||||
finishPendingRestore,
|
||||
undoRestore,
|
||||
} from "../src/client/state/reader/restore.ts";
|
||||
import { buildReaderExport } from "../src/client/state/reader/export.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
import { MemoryStorage } from "./helpers/reader.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const reader = () => migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
test("restore preserves a recovery copy and undo restores the previous data", () => {
|
||||
const s = new MemoryStorage(),
|
||||
before = reader(),
|
||||
after = { ...reader(), prefs: { interface: "relaxed" } };
|
||||
s.setItem(READER_KEYS.reader, JSON.stringify(before));
|
||||
expect(prepareRestore(s, buildReaderExport(after, {}, at), at)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
|
||||
"relaxed",
|
||||
);
|
||||
expect(undoRestore(s)).toEqual({ ok: true });
|
||||
expect(JSON.parse(s.getItem(READER_KEYS.reader)!)).toEqual(before);
|
||||
});
|
||||
test("failed live write keeps a prepared transaction for startup recovery", () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
|
||||
s.fail = READER_KEYS.reader;
|
||||
const after = { ...reader(), prefs: { interface: "relaxed" } };
|
||||
expect(prepareRestore(s, buildReaderExport(after, {}, at), at)).toEqual({
|
||||
ok: false,
|
||||
reason: "quota",
|
||||
});
|
||||
expect(s.getItem(READER_KEYS.pending)).not.toBeNull();
|
||||
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
|
||||
"structured",
|
||||
);
|
||||
s.fail = "";
|
||||
expect(finishPendingRestore(s)).toEqual({ ok: true });
|
||||
expect(JSON.parse(s.getItem(READER_KEYS.reader)!).prefs.interface).toBe(
|
||||
"relaxed",
|
||||
);
|
||||
});
|
||||
test("invalid payloads cannot overwrite live data or create a transaction", () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
|
||||
expect(
|
||||
prepareRestore(s, { format: "gacha-tracker-export", version: 999 }, at),
|
||||
).toEqual({ ok: false, reason: "invalid" });
|
||||
expect(s.getItem(READER_KEYS.pending)).toBeNull();
|
||||
});
|
||||
test("restore rejects orphan logs and references to missing clock revisions", () => {
|
||||
const s = new MemoryStorage();
|
||||
s.setItem(READER_KEYS.reader, JSON.stringify(reader()));
|
||||
const broken = reader();
|
||||
broken.data["account:missing"] = broken.data["account:one"]!;
|
||||
expect(prepareRestore(s, buildReaderExport(broken, {}, at), at)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalid",
|
||||
});
|
||||
delete broken.data["account:missing"];
|
||||
broken.data["account:one"]!.daily.missing = {};
|
||||
expect(prepareRestore(s, buildReaderExport(broken, {}, at), at)).toEqual({
|
||||
ok: false,
|
||||
reason: "invalid",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import {
|
||||
routineRows,
|
||||
placementFor,
|
||||
} from "../src/client/state/reader/routines.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("routine placements are independent and an absent new routine is not followed", () => {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const a = state.accounts["account:one"]!;
|
||||
a.routines["routine:genshin:weekly-bosses"] = {
|
||||
followed: true,
|
||||
checklist: false,
|
||||
timeline: true,
|
||||
};
|
||||
expect(placementFor(a, "new-routine")).toEqual({
|
||||
followed: false,
|
||||
checklist: false,
|
||||
timeline: false,
|
||||
});
|
||||
const rows = routineRows(state, a.id, Date.parse(at));
|
||||
expect(
|
||||
rows.find((r) => r.id === "routine:genshin:weekly-bosses")?.placement,
|
||||
).toEqual({ followed: true, checklist: false, timeline: true });
|
||||
});
|
||||
test("a changed reset reads only its own current revision without deleting older history", () => {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const a = state.accounts["account:one"]!;
|
||||
a.clocks.push({
|
||||
...a.clocks[0]!,
|
||||
id: "changed",
|
||||
effectiveAt: at,
|
||||
reset: { hour: 5, minute: 30 },
|
||||
});
|
||||
state.data[a.id]!.daily.initial = {
|
||||
"dailies:genshin": { days: ["2026-10-16"], at },
|
||||
};
|
||||
const row = routineRows(state, a.id, Date.parse(at)).find(
|
||||
(r) => r.id === "dailies:genshin",
|
||||
)!;
|
||||
expect(row.days).toEqual([]);
|
||||
expect(state.data[a.id]!.daily.initial).toHaveProperty("dailies:genshin");
|
||||
});
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
test("an event the reader marked daily remains a directly tickable routine", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.seenEvents.x = {
|
||||
accountId: "account:one",
|
||||
event: sampleEvent,
|
||||
firstSeenAt: at,
|
||||
lastSeenAt: at,
|
||||
clockRevision: "initial",
|
||||
};
|
||||
s.data["account:one"]!.progress[sampleEvent.id] = { daily: true, at };
|
||||
const row = routineRows(s, "account:one", Date.parse(at)).find(
|
||||
(r) => r.id === sampleEvent.id,
|
||||
);
|
||||
expect(row?.placement.followed).toBe(true);
|
||||
expect(row?.cadence).toBe("day");
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { createAccount } from "../src/client/state/reader/accounts.ts";
|
||||
import {
|
||||
manyServers,
|
||||
serverSuffix,
|
||||
} from "../src/client/state/reader/servers.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("a server is named only for a game tracked on more than one", () => {
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
() => `account:${Math.random()}`,
|
||||
);
|
||||
expect(manyServers(s, "genshin")).toBe(false);
|
||||
expect(serverSuffix(s, "genshin", "europe")).toBe("");
|
||||
s.accounts["account:asia"] = createAccount(
|
||||
"genshin",
|
||||
"asia",
|
||||
at,
|
||||
"account:asia",
|
||||
);
|
||||
expect(serverSuffix(s, "genshin", "asia")).toBe(" · asia");
|
||||
expect(serverSuffix(s, "hsr", "europe")).toBe("");
|
||||
// A retired second server stops counting, except where archives ask.
|
||||
s.accounts["account:asia"] = {
|
||||
...s.accounts["account:asia"]!,
|
||||
retiredAt: at,
|
||||
};
|
||||
expect(manyServers(s, "genshin")).toBe(false);
|
||||
expect(manyServers(s, "genshin", true)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { MemoryStorage } from "./helpers/reader.ts";
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createReaderStore,
|
||||
initializeReader,
|
||||
} from "../src/client/state/reader/store.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { READER_KEYS } from "../src/client/state/reader/schema.ts";
|
||||
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("two stores rebase unrelated field changes and reject older same-field writes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const a = createReaderStore(storage, initial),
|
||||
b = createReaderStore(storage, initial);
|
||||
expect(
|
||||
await a.dispatch({
|
||||
type: "progress",
|
||||
accountId: "account:one",
|
||||
itemId: "genshin:a:2026-10-01",
|
||||
patch: { status: "doing" },
|
||||
at,
|
||||
opId: "a",
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
expect(
|
||||
await b.dispatch({
|
||||
type: "progress",
|
||||
accountId: "account:one",
|
||||
itemId: "genshin:a:2026-10-01",
|
||||
patch: { note: "Keep me" },
|
||||
at,
|
||||
opId: "b",
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
const persisted = JSON.parse(storage.getItem(READER_KEYS.reader)!);
|
||||
expect(
|
||||
persisted.data["account:one"].progress["genshin:a:2026-10-01"],
|
||||
).toMatchObject({ status: "doing", note: "Keep me" });
|
||||
expect(
|
||||
await a.dispatch({
|
||||
type: "progress",
|
||||
accountId: "account:one",
|
||||
itemId: "genshin:a:2026-10-01",
|
||||
patch: { status: "done" },
|
||||
at: "2026-10-15T12:00:00.000Z",
|
||||
opId: "c",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "conflict" });
|
||||
});
|
||||
test("operation replay is idempotent, including toggles", async () => {
|
||||
const storage = new MemoryStorage(),
|
||||
s = createReaderStore(
|
||||
storage,
|
||||
migrateLegacy({}, ["genshin"], at, () => "account:one"),
|
||||
);
|
||||
const action = {
|
||||
type: "day" as const,
|
||||
accountId: "account:one",
|
||||
itemId: "dailies:genshin",
|
||||
revisionId: "initial",
|
||||
day: "2026-10-16",
|
||||
checked: true,
|
||||
at,
|
||||
opId: "same",
|
||||
};
|
||||
await s.dispatch(action);
|
||||
const rev = s.getSnapshot().revision;
|
||||
await s.dispatch(action);
|
||||
expect(s.getSnapshot().revision).toBe(rev);
|
||||
});
|
||||
test("failed migration preserves legacy bytes and never records success", () => {
|
||||
const storage = new MemoryStorage();
|
||||
storage.setItem("gacha-tracker:v1:prefs", '{"onboarded":true}');
|
||||
storage.fail = READER_KEYS.reader;
|
||||
const result = initializeReader(
|
||||
storage,
|
||||
["genshin"],
|
||||
at,
|
||||
() => "account:one",
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(storage.getItem("gacha-tracker:v1:prefs")).toBe('{"onboarded":true}');
|
||||
expect(storage.getItem(READER_KEYS.reader)).toBeNull();
|
||||
expect(storage.getItem(READER_KEYS.migrationRecovery)).not.toBeNull();
|
||||
});
|
||||
test("quota failure does not publish a successful in-memory edit", async () => {
|
||||
const storage = new MemoryStorage(),
|
||||
s = createReaderStore(
|
||||
storage,
|
||||
migrateLegacy({}, ["genshin"], at, () => "account:one"),
|
||||
);
|
||||
storage.fail = READER_KEYS.reader;
|
||||
expect(
|
||||
await s.dispatch({
|
||||
type: "prefs",
|
||||
patch: { interface: "relaxed" },
|
||||
at,
|
||||
opId: "a",
|
||||
}),
|
||||
).toEqual({ ok: false, reason: "quota" });
|
||||
expect(s.getSnapshot().prefs.interface).toBe("structured");
|
||||
});
|
||||
|
||||
test("new-event markers seed accounts together rather than queueing per-account writes", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
let n = 0;
|
||||
const s = createReaderStore(
|
||||
storage,
|
||||
migrateLegacy({}, ["genshin", "hsr"], at, () => `account:test${++n}`),
|
||||
);
|
||||
await s.dispatch({
|
||||
type: "seenBatch",
|
||||
entries: [
|
||||
{ id: "account:test1", ids: ["genshin:a"] },
|
||||
{ id: "account:test2", ids: ["hsr:a"] },
|
||||
],
|
||||
at,
|
||||
opId: "batch",
|
||||
});
|
||||
expect(s.getSnapshot().revision).toBe(1);
|
||||
expect(s.getSnapshot().eventSeen).toEqual({
|
||||
"account:test1": ["genshin:a"],
|
||||
"account:test2": ["hsr:a"],
|
||||
});
|
||||
});
|
||||
test("stale tabs preserve independent account routine choices", async () => {
|
||||
const storage = new MemoryStorage(),
|
||||
initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
|
||||
const a = createReaderStore(storage, initial),
|
||||
b = createReaderStore(storage, initial),
|
||||
account = initial.accounts["account:one"]!;
|
||||
await a.dispatch({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
first: { followed: true, checklist: true, timeline: false },
|
||||
},
|
||||
},
|
||||
at,
|
||||
opId: "first",
|
||||
});
|
||||
await b.dispatch({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
second: { followed: true, checklist: false, timeline: true },
|
||||
},
|
||||
},
|
||||
at,
|
||||
opId: "second",
|
||||
});
|
||||
expect(b.getSnapshot().accounts[account.id]!.routines.first).toHaveProperty(
|
||||
"followed",
|
||||
true,
|
||||
);
|
||||
expect(b.getSnapshot().accounts[account.id]!.routines.second).toHaveProperty(
|
||||
"followed",
|
||||
true,
|
||||
);
|
||||
});
|
||||
test("resuming a retired account removes its retirement without erasing history", async () => {
|
||||
const storage = new MemoryStorage(),
|
||||
initial = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
initial.accounts["account:one"]!.retiredAt = at;
|
||||
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
|
||||
const store = createReaderStore(storage, initial),
|
||||
account = { ...initial.accounts["account:one"]! };
|
||||
delete account.retiredAt;
|
||||
expect(
|
||||
await store.dispatch({ type: "account", account, at, opId: "resume" }),
|
||||
).toEqual({ ok: true });
|
||||
expect(store.getSnapshot().accounts[account.id]!.retiredAt).toBeUndefined();
|
||||
});
|
||||
test("stale tabs preserve independent pins on the same routine", async () => {
|
||||
const storage = new MemoryStorage(),
|
||||
initial = migrateLegacy({}, ["genshin"], at, () => "account:one"),
|
||||
account = initial.accounts["account:one"]!;
|
||||
account.routines.test = { followed: true, checklist: false, timeline: false };
|
||||
storage.setItem(READER_KEYS.reader, JSON.stringify(initial));
|
||||
const a = createReaderStore(storage, initial),
|
||||
b = createReaderStore(storage, initial);
|
||||
await a.dispatch({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
test: { ...account.routines.test!, checklist: true },
|
||||
},
|
||||
},
|
||||
at,
|
||||
opId: "checklist",
|
||||
});
|
||||
await b.dispatch({
|
||||
type: "account",
|
||||
account: {
|
||||
...account,
|
||||
routines: {
|
||||
...account.routines,
|
||||
test: { ...account.routines.test!, timeline: true },
|
||||
},
|
||||
},
|
||||
at,
|
||||
opId: "timeline",
|
||||
});
|
||||
expect(b.getSnapshot().accounts[account.id]!.routines.test).toEqual({
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
staleSnooze,
|
||||
updateItems,
|
||||
} from "../src/client/state/reader/updates.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import type { EventFeed } from "../src/shared/feed.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("source warning follows tracked games, independent of focus; empty updates disappear", () => {
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
(() => {
|
||||
let n = 0;
|
||||
return () => `account:a${++n}`;
|
||||
})(),
|
||||
);
|
||||
s.accounts["account:a2"]!.intent.events = false;
|
||||
s.accounts["account:a2"]!.routines = {};
|
||||
s.prefs.focusGame = "hsr";
|
||||
const source = {
|
||||
sourceId: "genshin-events",
|
||||
game: "genshin" as const,
|
||||
url: "https://example.com",
|
||||
lastSuccessAt: null,
|
||||
lastConfirmedAt: null,
|
||||
contentChangedAt: null,
|
||||
eventCount: 0,
|
||||
parsedCount: null,
|
||||
statesNoEvents: false,
|
||||
};
|
||||
const feed: EventFeed = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: at,
|
||||
events: [],
|
||||
sources: [source],
|
||||
};
|
||||
expect(updateItems(s, feed, Date.parse(at)).map((r) => r.kind)).toEqual([
|
||||
"sources",
|
||||
]);
|
||||
expect(
|
||||
updateItems(
|
||||
s,
|
||||
{ ...feed, sources: [{ ...source, lastSuccessAt: at }] },
|
||||
Date.parse(at),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
test("a snoozed source warning stays hidden until it expires or another game goes stale", () => {
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
(() => {
|
||||
let n = 0;
|
||||
return () => `account:a${++n}`;
|
||||
})(),
|
||||
);
|
||||
const source = (game: "genshin" | "hsr") => ({
|
||||
sourceId: `${game}-events`,
|
||||
game,
|
||||
url: "https://example.com",
|
||||
lastSuccessAt: null,
|
||||
lastConfirmedAt: null,
|
||||
contentChangedAt: null,
|
||||
eventCount: 0,
|
||||
parsedCount: null,
|
||||
statesNoEvents: false,
|
||||
});
|
||||
const feed: EventFeed = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: at,
|
||||
events: [],
|
||||
sources: [source("genshin")],
|
||||
};
|
||||
const now = Date.parse(at),
|
||||
day = 86_400_000;
|
||||
s.prefs.staleSnooze = staleSnooze(["genshin"], now, 7);
|
||||
expect(s.prefs.staleSnooze).toEqual({
|
||||
until: new Date(now + 7 * day).toISOString(),
|
||||
games: ["genshin"],
|
||||
});
|
||||
expect(updateItems(s, feed, now + 6 * day)).toEqual([]);
|
||||
expect(updateItems(s, feed, now + 7 * day).map((r) => r.kind)).toEqual([
|
||||
"sources",
|
||||
]);
|
||||
expect(
|
||||
updateItems(
|
||||
s,
|
||||
{ ...feed, sources: [source("genshin"), source("hsr")] },
|
||||
now + day,
|
||||
),
|
||||
).toEqual([{ kind: "sources", games: ["genshin", "hsr"] }]);
|
||||
// Never longer than seven days, whatever is asked.
|
||||
expect(staleSnooze(["genshin"], now, 30).until).toBe(
|
||||
new Date(now + 7 * day).toISOString(),
|
||||
);
|
||||
s.prefs.staleSnooze = "garbage";
|
||||
expect(updateItems(s, feed, now).map((r) => r.kind)).toEqual(["sources"]);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { ChangelogPage } from "../src/client/components/redesign/ChangelogPage.tsx";
|
||||
import { ENTRIES, NOTES } from "../src/shared/changelog.ts";
|
||||
|
||||
test("the changelog keeps every commit, its kind and the note's specs in the mockup layout", () => {
|
||||
const html = render(<ChangelogPage notes={NOTES} entries={ENTRIES} />);
|
||||
expect(html).toContain('class="rel"');
|
||||
for (const entry of ENTRIES) expect(html).toContain(`>${entry.ref}<`);
|
||||
for (const label of ["New", "Fixed", "Games & sources"])
|
||||
expect(html).toContain(label);
|
||||
const withSpec = NOTES.find((n) => (n.specs ?? []).length > 0);
|
||||
if (withSpec)
|
||||
expect(html).toContain(`Spec: ${withSpec.specs![0]!.split("/").at(-1)}`);
|
||||
expect(html).toContain("Other changes");
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
ChecklistEvent,
|
||||
LedgerColumns,
|
||||
NextDeadline,
|
||||
} from "../src/client/components/redesign/ChecklistEvent.tsx";
|
||||
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const rows = () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
const events = [1, 2, 3, 4, 5].map((n) => ({
|
||||
...sampleEvent,
|
||||
id: `${sampleEvent.id}-${n}`,
|
||||
title: `Event ${n}`,
|
||||
summary: null,
|
||||
endsAt: new Date(Date.parse(at) + n * 86_400_000).toISOString(),
|
||||
}));
|
||||
return readerEventRows(s, events, Date.parse(at)).filter((r) => r.clock.live);
|
||||
};
|
||||
test("every row has a time-left bar, with or without a description", () => {
|
||||
const [row] = rows();
|
||||
const html = render(
|
||||
<ChecklistEvent
|
||||
row={row!}
|
||||
now={Date.parse(at)}
|
||||
compact
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('class="ledger-row"');
|
||||
expect(html).toContain('class="gcell"');
|
||||
expect(html).toContain('class="meter"');
|
||||
expect(html).not.toContain("event-entry");
|
||||
expect(render(<LedgerColumns />)).toContain("Time left");
|
||||
// Standard rows without a description show the time-left bar too (the
|
||||
// owner chose this over the mockup's shorter row).
|
||||
expect(
|
||||
render(
|
||||
<ChecklistEvent row={row!} now={Date.parse(at)} onOpen={() => {}} />,
|
||||
),
|
||||
).toContain('class="meter"');
|
||||
});
|
||||
test("the next deadline lists the following three, as in the Round 6 rail", () => {
|
||||
const live = rows();
|
||||
const html = render(
|
||||
<NextDeadline row={live[0]} then={live.slice(1, 4)} onOpen={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('class="then"');
|
||||
for (const n of [2, 3, 4]) expect(html).toContain(`Event ${n}`);
|
||||
expect(html).not.toContain("Event 5");
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { DailyPanel } from "../src/client/components/redesign/DailyPanel.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { routineRows } from "../src/client/state/reader/routines.ts";
|
||||
import { readerDayKey } from "../src/shared/readerClock.ts";
|
||||
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const now = Date.parse(at);
|
||||
function setup() {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:daily");
|
||||
state.accounts["account:daily"]!.routines["dailies:genshin"] = {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: false,
|
||||
};
|
||||
const rows = routineRows(state, "account:daily", now).filter(
|
||||
(r) => r.id === "dailies:genshin",
|
||||
);
|
||||
return { state, rows };
|
||||
}
|
||||
|
||||
test("a daily row is one tick target, with progress above the list", () => {
|
||||
const { state, rows } = setup();
|
||||
const html = render(
|
||||
<DailyPanel
|
||||
rows={rows}
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={now}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('class="daily-row daily-hit"');
|
||||
expect(html).toContain('aria-pressed="false"');
|
||||
expect(html).toContain("0 of 1 done");
|
||||
expect(html).not.toContain("-day streak");
|
||||
});
|
||||
|
||||
test("a ticked daily reads done, shows its streak, and the panel says all done", () => {
|
||||
const { state, rows } = setup();
|
||||
const today = readerDayKey(now, rows[0]!.clock);
|
||||
const yesterday = new Date(Date.parse(`${today}T00:00:00Z`) - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
const ticked = [{ ...rows[0]!, days: [yesterday, today] }];
|
||||
const html = render(
|
||||
<DailyPanel
|
||||
rows={ticked}
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={now}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
expect(html).toContain("All done for today");
|
||||
expect(html).toContain("2-day streak");
|
||||
// A fresh render is not a fresh tick: nothing pops on arrival.
|
||||
expect(html).not.toContain("data-fresh");
|
||||
expect(html).not.toContain("fireworks");
|
||||
});
|
||||
|
||||
test("an event's weekly goal, met without today's tick, reads done and shows 5/5 wk", () => {
|
||||
const { state, rows } = setup();
|
||||
const today = readerDayKey(now, rows[0]!.clock);
|
||||
const goalRow = {
|
||||
...rows[0]!,
|
||||
id: "genshin:to-temper-thyself",
|
||||
title: "To Temper Thyself and Journey Far",
|
||||
days: [],
|
||||
status: {
|
||||
scope: "week" as const,
|
||||
measure: "days" as const,
|
||||
periodStart: today,
|
||||
required: 5,
|
||||
count: 5,
|
||||
met: true,
|
||||
needed: 0,
|
||||
open: 0,
|
||||
shortfall: false,
|
||||
},
|
||||
};
|
||||
const html = render(
|
||||
<DailyPanel
|
||||
rows={[goalRow]}
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={now}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("5/5 wk");
|
||||
expect(html).toContain('data-met="true"');
|
||||
// Not ticked today: the button stays unpressed, nothing is invented.
|
||||
expect(html).toContain('aria-pressed="false"');
|
||||
expect(html).toContain("All done for today");
|
||||
expect(html).not.toContain("-day streak");
|
||||
});
|
||||
|
||||
test("an unmet weekly goal counts toward its target, not as done", () => {
|
||||
const { state, rows } = setup();
|
||||
const today = readerDayKey(now, rows[0]!.clock);
|
||||
const goalRow = {
|
||||
...rows[0]!,
|
||||
id: "genshin:to-temper-thyself",
|
||||
days: [],
|
||||
status: {
|
||||
scope: "week" as const,
|
||||
measure: "days" as const,
|
||||
periodStart: today,
|
||||
required: 5,
|
||||
count: 3,
|
||||
met: false,
|
||||
needed: 2,
|
||||
open: 4,
|
||||
shortfall: false,
|
||||
},
|
||||
};
|
||||
const html = render(
|
||||
<DailyPanel
|
||||
rows={[goalRow]}
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={now}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("3/5 wk");
|
||||
expect(html).toContain("0 of 1 done");
|
||||
expect(html).not.toContain("data-met");
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { SetupWizard } from "../src/client/components/redesign/SetupWizard.tsx";
|
||||
import { ReturningTour } from "../src/client/components/redesign/ReturningTour.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
test("initial preferences follow the Round 6 setup and defer upcoming events to tracking", () => {
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
[],
|
||||
"2026-10-16T12:00:00.000Z",
|
||||
() => "account:test",
|
||||
);
|
||||
const html = render(<SetupWizard state={s} onFinish={async () => {}} />);
|
||||
for (const label of [
|
||||
"Step 1 of 4",
|
||||
"Make it yours",
|
||||
"Default game server",
|
||||
"Appearance",
|
||||
"Colour scheme",
|
||||
"Your timezone",
|
||||
"Next: choose your games",
|
||||
])
|
||||
expect(html).toContain(label);
|
||||
expect(html).not.toContain("Show events that haven't started");
|
||||
});
|
||||
test("returning readers can browse immediately without restarting setup", () => {
|
||||
const html = render(<ReturningTour onStep={() => {}} onFinish={() => {}} />);
|
||||
expect(html).toContain("Your games, routines and progress are all still here");
|
||||
expect(html).toContain("Keep browsing");
|
||||
expect(html).toContain("Take the quick tour");
|
||||
});
|
||||
|
||||
import { TOUR_STEPS } from "../src/client/components/redesign/ReturningTour.tsx";
|
||||
test("the tour introduces the Timeline right after the Checklist, and codes", () => {
|
||||
const stops = TOUR_STEPS.map((s) => s.stop);
|
||||
expect(stops.indexOf("timeline")).toBe(stops.indexOf("soon") + 1);
|
||||
expect(stops).toContain("codes");
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
PinnedRoutine,
|
||||
lastDayInPeriod,
|
||||
} from "../src/client/components/redesign/PinnedRoutine.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { routineRows } from "../src/client/state/reader/routines.ts";
|
||||
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const now = Date.parse(at);
|
||||
function row(id: string) {
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:pin");
|
||||
state.accounts["account:pin"]!.routines[id] = {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: false,
|
||||
};
|
||||
return routineRows(state, "account:pin", now).find((r) => r.id === id)!;
|
||||
}
|
||||
const ok = async () => ({ ok: true }) as const;
|
||||
|
||||
test("an amount routine short of its target offers +1, an editable count and one-click complete", () => {
|
||||
const r = row("routine:genshin:weekly-bosses");
|
||||
const html = render(
|
||||
<PinnedRoutine row={r} hue="#4EA8DE" onAction={ok} now={now} />,
|
||||
);
|
||||
expect(html).toContain("0 / 3 runs");
|
||||
expect(html).toContain("+1");
|
||||
expect(html).toContain("Mark Weekly Bosses complete");
|
||||
expect(html).toContain('aria-pressed="false"');
|
||||
expect(html).not.toContain("data-done");
|
||||
});
|
||||
|
||||
test("a met target reads done this week, drops +1, and its box offers undo", () => {
|
||||
const r = row("routine:genshin:weekly-bosses");
|
||||
const met = { ...r, status: { ...r.status, count: 3, met: true, needed: 0 } };
|
||||
const html = render(
|
||||
<PinnedRoutine row={met} hue="#4EA8DE" onAction={ok} now={now} />,
|
||||
);
|
||||
expect(html).toContain('data-done="true"');
|
||||
expect(html).toContain("Done this week");
|
||||
expect(html).toContain("3 / 3 runs");
|
||||
expect(html).not.toContain("+1");
|
||||
expect(html).toContain("done this week. Undo");
|
||||
expect(html).toContain('aria-pressed="true"');
|
||||
// Arriving complete is not completing it: no fireworks on render.
|
||||
expect(html).not.toContain("fireworks");
|
||||
});
|
||||
|
||||
test("undoing a check-in removes the period's latest tick, not one from before it", () => {
|
||||
const r = row("routine:genshin:realm-depot");
|
||||
const today = "2026-10-16";
|
||||
const ticked = {
|
||||
...r,
|
||||
days: ["2026-10-08", "2026-10-13", "2026-10-14"],
|
||||
status: { ...r.status, periodStart: "2026-10-12" },
|
||||
};
|
||||
expect(lastDayInPeriod(ticked, today)).toBe("2026-10-14");
|
||||
expect(
|
||||
lastDayInPeriod({ ...ticked, days: ["2026-10-08"] }, today),
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
RoutineDrawer,
|
||||
scheduleWords,
|
||||
sourceLabel,
|
||||
} from "../src/client/components/redesign/RoutineDrawer.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { createAccount } from "../src/client/state/reader/accounts.ts";
|
||||
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const drawer = (
|
||||
pane: "library" | "create",
|
||||
state = migrateLegacy({}, ["genshin"], at, () => "account:one"),
|
||||
) =>
|
||||
render(
|
||||
<RoutineDrawer
|
||||
state={state}
|
||||
now={Date.parse(at)}
|
||||
pane={pane}
|
||||
editing={null}
|
||||
onPane={() => {}}
|
||||
onClose={() => {}}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onSave={async () => true}
|
||||
/>,
|
||||
);
|
||||
|
||||
test("schedule lines read like the mockup", () => {
|
||||
expect(scheduleWords({ unit: "week", startsOn: 1 }, 4)).toBe(
|
||||
"Every Monday at 04:00",
|
||||
);
|
||||
expect(scheduleWords({ unit: "month", startsOn: 16 }, 4)).toBe(
|
||||
"Every month on the 16th at 04:00",
|
||||
);
|
||||
expect(scheduleWords({ unit: "day" }, 20)).toBe("Every day at 20:00");
|
||||
expect(
|
||||
sourceLabel("https://genshin-impact.fandom.com/wiki/Weekly_Boss"),
|
||||
).toBe("Community wiki");
|
||||
});
|
||||
|
||||
test("the library is a shelf of built-ins with bulk follows and dated sources", () => {
|
||||
const html = drawer("library");
|
||||
expect(html).toContain('class="routine-drawer"');
|
||||
expect(html).toContain("Create your own routine");
|
||||
expect(html).toContain("New follows also show in");
|
||||
expect(html).toContain("Follow all weeklies");
|
||||
expect(html).toContain("Every Monday at 04:00 · 3 runs · Community wiki");
|
||||
expect(html).toContain(">Follow<");
|
||||
});
|
||||
|
||||
test("a game on two servers gets one follow button per server", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.accounts["account:asia"] = createAccount(
|
||||
"genshin",
|
||||
"asia",
|
||||
at,
|
||||
"account:asia",
|
||||
);
|
||||
s.data["account:asia"] = {
|
||||
progress: {},
|
||||
ignored: {},
|
||||
daily: {},
|
||||
amounts: {},
|
||||
};
|
||||
const html = drawer("library", s);
|
||||
expect(html).toContain(">EU<");
|
||||
expect(html).toContain(">Asia<");
|
||||
expect(html).toContain("2 servers");
|
||||
});
|
||||
|
||||
test("the create shelf asks every mockup question and previews the result", () => {
|
||||
const html = drawer("create");
|
||||
for (const label of [
|
||||
"Start from an example",
|
||||
"What is it",
|
||||
"Repeats",
|
||||
"Every day",
|
||||
"Resets at",
|
||||
"Another hour",
|
||||
"Done when",
|
||||
"Note",
|
||||
"Link",
|
||||
"Also show in",
|
||||
"Preview",
|
||||
"Create and follow",
|
||||
])
|
||||
expect(html).toContain(label);
|
||||
expect(html).toContain("Still needed: a name.");
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { RoutineGroup } from "../src/client/components/redesign/RoutineGroup.tsx";
|
||||
test("always expanded groups have no collapse affordance", () => {
|
||||
const html = render(
|
||||
<RoutineGroup title="Genshin" policy="always" focused={false}>
|
||||
<p>Direct daily controls</p>
|
||||
</RoutineGroup>,
|
||||
);
|
||||
expect(html).not.toContain("<summary");
|
||||
expect(html).toContain("Direct daily controls");
|
||||
});
|
||||
test("focus expands groups without changing the saved default", () => {
|
||||
const html = render(
|
||||
<RoutineGroup title="Genshin" policy="collapsed" focused={true}>
|
||||
<p>Controls</p>
|
||||
</RoutineGroup>,
|
||||
);
|
||||
expect(html).toContain('open=""');
|
||||
});
|
||||
|
||||
import { placementAccounts } from "../src/client/components/redesign/RoutinesPage.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { routineRows } from "../src/client/state/reader/routines.ts";
|
||||
test("bulk pins combine changes per account and preserve the other placement", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:bulk");
|
||||
const a = state.accounts["account:bulk"]!;
|
||||
a.routines["routine:genshin:weekly-bosses"] = {
|
||||
followed: true,
|
||||
checklist: false,
|
||||
timeline: true,
|
||||
};
|
||||
const rows = routineRows(state, a.id, Date.parse(at));
|
||||
expect(rows.length).toBeGreaterThan(1);
|
||||
const accounts = placementAccounts(state, rows, "checklist", true);
|
||||
expect(accounts).toHaveLength(1);
|
||||
for (const row of rows) {
|
||||
expect(accounts[0]!.routines[row.id]!.checklist).toBe(true);
|
||||
expect(accounts[0]!.routines[row.id]!.timeline).toBe(
|
||||
row.placement.timeline,
|
||||
);
|
||||
}
|
||||
expect(a.routines["routine:genshin:weekly-bosses"]!.checklist).toBe(false);
|
||||
});
|
||||
|
||||
import { RoutinesPage } from "../src/client/components/redesign/RoutinesPage.tsx";
|
||||
test("the routines table shows progress as text with an edit affordance, not inputs", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:table");
|
||||
const pin = { followed: true, checklist: true, timeline: true };
|
||||
state.accounts["account:table"]!.routines["routine:genshin:weekly-bosses"] =
|
||||
pin;
|
||||
state.accounts["account:table"]!.routines["dailies:genshin"] = pin;
|
||||
const html = render(
|
||||
<RoutinesPage
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={Date.parse(at)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('class="rt"');
|
||||
expect(html).toContain('class="gcell"');
|
||||
expect(html).toContain('title="Enter an amount"');
|
||||
expect(html).toContain("/ 3 runs");
|
||||
expect(html).not.toContain('type="number"');
|
||||
expect(html).toContain('class="pin"');
|
||||
// One server: the game cell does not name it.
|
||||
expect(html).not.toContain(">europe<");
|
||||
});
|
||||
|
||||
test("routine bands are named by cadence: Dailies, Weeklies", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const state = migrateLegacy({}, ["genshin"], at, () => "account:bands");
|
||||
const pin = { followed: true, checklist: true, timeline: true };
|
||||
state.accounts["account:bands"]!.routines["routine:genshin:weekly-bosses"] =
|
||||
pin;
|
||||
state.accounts["account:bands"]!.routines["dailies:genshin"] = pin;
|
||||
const html = render(
|
||||
<RoutinesPage
|
||||
state={state}
|
||||
onAction={async () => ({ ok: true })}
|
||||
now={Date.parse(at)}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain(">Dailies<");
|
||||
expect(html).toContain(">Weeklies<");
|
||||
expect(html).not.toContain(">Today<");
|
||||
expect(html).not.toContain(">This week<");
|
||||
});
|
||||
|
||||
import { Fireworks } from "../src/client/components/Fireworks.tsx";
|
||||
test("the grand celebration is wider than the single burst and carries the game's colour", () => {
|
||||
const single = render(<Fireworks />);
|
||||
const grand = render(<Fireworks grand hue="#4EA8DE" />);
|
||||
const count = (html: string) => html.split('class="spark"').length - 1;
|
||||
expect(count(single)).toBe(27);
|
||||
expect(count(grand)).toBe(98);
|
||||
expect(grand).toContain('class="fireworks grand"');
|
||||
expect(grand).toContain("background:#4EA8DE");
|
||||
});
|
||||
|
||||
test("a finished routine group is marked complete but does not celebrate on arrival", () => {
|
||||
const html = render(
|
||||
<RoutineGroup
|
||||
title="Genshin"
|
||||
policy="always"
|
||||
focused={false}
|
||||
done={2}
|
||||
total={2}
|
||||
summary="All 2 done"
|
||||
>
|
||||
<p>rows</p>
|
||||
</RoutineGroup>,
|
||||
);
|
||||
expect(html).toContain('data-complete="true"');
|
||||
expect(html).toContain("All 2 done");
|
||||
expect(html).not.toContain("data-celebrate");
|
||||
expect(html).not.toContain("fireworks");
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { AboutPage } from "../src/client/components/redesign/AboutPage.tsx";
|
||||
import {
|
||||
DataSourcesPage,
|
||||
sourceReason,
|
||||
} from "../src/client/components/redesign/DataSourcesPage.tsx";
|
||||
import type { SourceHealth } from "../src/shared/feed.ts";
|
||||
|
||||
const now = Date.parse("2026-10-17T12:00:00.000Z");
|
||||
const hoursAgo = (h: number) => new Date(now - h * 3_600_000).toISOString();
|
||||
const source = (over: Partial<SourceHealth> = {}): SourceHealth => ({
|
||||
sourceId: "genshin-events",
|
||||
game: "genshin",
|
||||
url: "https://genshin-impact.fandom.com/wiki/Event",
|
||||
lastSuccessAt: hoursAgo(2),
|
||||
lastConfirmedAt: hoursAgo(2),
|
||||
contentChangedAt: hoursAgo(6),
|
||||
eventCount: 3,
|
||||
parsedCount: null,
|
||||
statesNoEvents: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("About holds credits and the disclaimer, not data freshness", () => {
|
||||
const html = render(<AboutPage sources={[source()]} />);
|
||||
for (const part of [
|
||||
"Made by",
|
||||
"With thanks to the source editors",
|
||||
"The studios and games",
|
||||
"Ideas and acknowledgements",
|
||||
"Unofficial and unaffiliated",
|
||||
"Genshin Impact",
|
||||
])
|
||||
expect(html).toContain(part);
|
||||
expect(html).toContain('class="game-name" style="color:');
|
||||
expect(html).not.toContain("See data freshness");
|
||||
expect(html).not.toContain("last refreshed");
|
||||
});
|
||||
|
||||
test("Data & sources holds the refresh time and one row per game", () => {
|
||||
const html = render(
|
||||
<DataSourcesPage
|
||||
sources={[
|
||||
source(),
|
||||
source({ sourceId: "genshin-game8", url: "https://game8.co/x" }),
|
||||
]}
|
||||
now={now}
|
||||
timezone={null}
|
||||
onRefresh={async () => "same"}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Event data last refreshed");
|
||||
expect(html).toContain("Check for new data");
|
||||
expect(html.match(/class="source-row"/g)?.length).toBe(1);
|
||||
expect(html).toContain("Last successful check:");
|
||||
expect(html).toContain("Check source page");
|
||||
});
|
||||
|
||||
test("each source reads as one of the mockup's four states", () => {
|
||||
expect(sourceReason(source(), now).current).toBe(true);
|
||||
expect(
|
||||
sourceReason(source({ contentChangedAt: hoursAgo(24 * 9) }), now).text,
|
||||
).toContain("Site has no new updates");
|
||||
expect(
|
||||
sourceReason(
|
||||
source({
|
||||
lastSuccessAt: hoursAgo(24 * 6),
|
||||
lastConfirmedAt: hoursAgo(24 * 6),
|
||||
}),
|
||||
now,
|
||||
).text,
|
||||
).toContain("Pull overdue");
|
||||
expect(
|
||||
sourceReason(
|
||||
source({
|
||||
lastSuccessAt: null,
|
||||
lastConfirmedAt: null,
|
||||
contentChangedAt: null,
|
||||
}),
|
||||
now,
|
||||
).text,
|
||||
).toContain("No successful source fetch");
|
||||
});
|
||||
|
||||
test("Data & sources offers the standard-banner pulls choice beside the other list options", () => {
|
||||
const html = render(
|
||||
<DataSourcesPage
|
||||
sources={[source()]}
|
||||
now={now}
|
||||
timezone={null}
|
||||
showUpcoming={false}
|
||||
detectDaily={false}
|
||||
standardPulls={true}
|
||||
onPrefs={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Offer standard-banner pulls in the library");
|
||||
expect(html).toContain("Premium pulls are always offered.");
|
||||
expect(html.indexOf("Offer standard-banner pulls")).toBeLessThan(
|
||||
html.indexOf('class="source-row"'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
AppNavigation,
|
||||
mainViewPatch,
|
||||
} from "../src/client/components/redesign/AppNavigation.tsx";
|
||||
import { SettingsLayout } from "../src/client/components/redesign/SettingsLayout.tsx";
|
||||
import { ChangelogPage } from "../src/client/components/redesign/ChangelogPage.tsx";
|
||||
|
||||
test("utility pages do not replace the reader's preferred main view", () => {
|
||||
expect(mainViewPatch("settings")).toEqual({});
|
||||
expect(mainViewPatch("archive")).toEqual({});
|
||||
expect(mainViewPatch("timeline")).toEqual({ view: "timeline" });
|
||||
const html = render(<AppNavigation page="settings" onPage={() => {}} />);
|
||||
for (const label of [
|
||||
"Checklist",
|
||||
"Timeline",
|
||||
"Routines",
|
||||
"Archive",
|
||||
"Settings",
|
||||
])
|
||||
expect(html).toContain(label);
|
||||
expect(html).toContain('aria-current="page"');
|
||||
});
|
||||
test("settings has named navigation and one content region", () => {
|
||||
const html = render(
|
||||
<SettingsLayout page="appearance" onPage={() => {}}>
|
||||
<p>Appearance controls</p>
|
||||
</SettingsLayout>,
|
||||
);
|
||||
expect(html).toContain('aria-label="Settings pages"');
|
||||
expect(html).toContain("Appearance controls");
|
||||
expect(html).toContain("Changelog");
|
||||
});
|
||||
test("changelog page has release content without a scrim or modal", () => {
|
||||
const html = render(
|
||||
<ChangelogPage
|
||||
notes={[]}
|
||||
entries={[
|
||||
{
|
||||
id: "test",
|
||||
date: "2026-10-16",
|
||||
title: "A real entry",
|
||||
kind: "feature",
|
||||
ref: "abcdef1",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("A real entry");
|
||||
expect(html).not.toContain('role="dialog"');
|
||||
expect(html).not.toContain("Close changelog");
|
||||
});
|
||||
import { NextUp } from "../src/client/components/NextUp.tsx";
|
||||
import { clockFor } from "../src/shared/time.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
test("headline identifies the server when accounts share an event title", () => {
|
||||
const html = render(
|
||||
<NextUp
|
||||
rows={[
|
||||
{
|
||||
event: sampleEvent,
|
||||
clock: clockFor(
|
||||
sampleEvent,
|
||||
"asia",
|
||||
Date.parse("2026-10-16T12:00:00Z"),
|
||||
),
|
||||
accountId: "account:asia",
|
||||
region: "asia",
|
||||
},
|
||||
]}
|
||||
focused={null}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Asia");
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
zoneGroups,
|
||||
zoneName,
|
||||
} from "../src/client/components/redesign/TimezoneSelect.tsx";
|
||||
import { TimeSettings } from "../src/client/components/redesign/TimeSettings.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
|
||||
test("zones read as cities, grouped by region", () => {
|
||||
expect(zoneName("Europe/Copenhagen")).toBe("Copenhagen");
|
||||
expect(zoneName("America/Argentina/Buenos_Aires")).toBe(
|
||||
"Buenos Aires, Argentina",
|
||||
);
|
||||
const groups = zoneGroups([
|
||||
"Europe/Paris",
|
||||
"UTC",
|
||||
"Asia/Tokyo",
|
||||
"Europe/Berlin",
|
||||
]);
|
||||
expect(groups.map((g) => g.group)).toEqual(["Europe", "Asia", "Other"]);
|
||||
expect(groups[0]!.zones.map((z) => z.name)).toEqual(["Berlin", "Paris"]);
|
||||
});
|
||||
|
||||
test("Region & time has one timezone list, no search box, and a reset row per server", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const s = migrateLegacy(
|
||||
{},
|
||||
["genshin", "hsr"],
|
||||
at,
|
||||
(() => {
|
||||
let n = 0;
|
||||
return () => `account:a${++n}`;
|
||||
})(),
|
||||
);
|
||||
const html = render(
|
||||
<TimeSettings
|
||||
state={s}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('type="search"');
|
||||
expect(html).toContain('<optgroup label="Europe">');
|
||||
expect(html).toContain("Currently displaying");
|
||||
expect(html.match(/class="reset-row"/g)?.length).toBe(2);
|
||||
expect(html).toContain("Reset timing for your games");
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import {
|
||||
TimelinePage,
|
||||
timelineLook,
|
||||
} from "../src/client/components/redesign/TimelinePage.tsx";
|
||||
import { focusBarShown } from "../src/client/components/redesign/FocusPicker.tsx";
|
||||
import { openingLeft } from "../src/client/components/Timeline.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { readerEventRows } from "../src/client/state/reader/eventRows.ts";
|
||||
import { sampleEvent } from "./helpers/reader.ts";
|
||||
test("a game's reset strip follows its events and precedes its full routine rows", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
s.prefs.resetStrips = true;
|
||||
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: true,
|
||||
};
|
||||
const html = render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html.indexOf('class="reset-strip"')).toBeGreaterThan(
|
||||
html.indexOf("View Test"),
|
||||
);
|
||||
expect(html.indexOf('class="routine-timeline-row"')).toBeGreaterThan(
|
||||
html.indexOf('class="reset-strip"'),
|
||||
);
|
||||
// A divider says where the routines begin.
|
||||
expect(html.indexOf('class="routine-divider"')).toBeGreaterThan(
|
||||
html.indexOf('class="reset-strip"'),
|
||||
);
|
||||
expect(html).toContain("Show routines in own section");
|
||||
});
|
||||
test("focus excludes other games' routine-only timelines", () => {
|
||||
let n = 0;
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin", "hsr"], at, () => `account:test${++n}`);
|
||||
s.prefs.focusGame = "genshin";
|
||||
for (const a of Object.values(s.accounts))
|
||||
a.routines[`dailies:${a.game}`] = {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: true,
|
||||
};
|
||||
const html = render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
rows={readerEventRows(s, [sampleEvent], Date.parse(at)).filter(
|
||||
(r) => r.event.game === "genshin",
|
||||
)}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("Honkai: Star Rail");
|
||||
});
|
||||
|
||||
test("Ending soonest places pinned routine resets in the same deadline queue as events", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
now = Date.parse(at),
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
s.prefs.timelineGroup = "ending";
|
||||
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = {
|
||||
followed: true,
|
||||
checklist: true,
|
||||
timeline: true,
|
||||
};
|
||||
const html = render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
rows={readerEventRows(
|
||||
s,
|
||||
[{ ...sampleEvent, endsAt: "2026-10-30T12:00:00.000Z" }],
|
||||
now,
|
||||
)}
|
||||
now={now}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html.indexOf('class="routine-timeline-row"')).toBeGreaterThan(-1);
|
||||
expect(html.indexOf('class="routine-timeline-row"')).toBeLessThan(
|
||||
html.indexOf('aria-label="View Test details"'),
|
||||
);
|
||||
});
|
||||
|
||||
test("dailies stay off the timeline even when pinned there; longer routines show", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
const pin = { followed: true, checklist: true, timeline: true };
|
||||
s.accounts["account:test"]!.routines["dailies:genshin"] = pin;
|
||||
s.accounts["account:test"]!.routines["routine:genshin:weekly-bosses"] = pin;
|
||||
const html = render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Weekly Bosses");
|
||||
expect(html).not.toContain("Commissions, resin");
|
||||
});
|
||||
test("the new look is the default; end dates replace time left when chosen", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
const page = () =>
|
||||
render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(timelineLook(s.prefs)).toBe("new");
|
||||
expect(page()).toContain(" left</span>");
|
||||
s.prefs.timelineLabel = "end";
|
||||
expect(page()).toContain(">Ends ");
|
||||
s.prefs.timelineLook = "classic";
|
||||
expect(timelineLook(s.prefs)).toBe("classic");
|
||||
expect(page()).toContain('class="classic-timeline"');
|
||||
expect(page()).not.toContain('class="timeline-board"');
|
||||
});
|
||||
test("the focus bar setting defaults to both views and can hide either", () => {
|
||||
expect(focusBarShown({}, "soon")).toBe(true);
|
||||
expect(focusBarShown({}, "timeline")).toBe(true);
|
||||
const prefs = { focusBar: { checklist: true, timeline: false } };
|
||||
expect(focusBarShown(prefs, "soon")).toBe(true);
|
||||
expect(focusBarShown(prefs, "timeline")).toBe(false);
|
||||
expect(focusBarShown({ focusBar: "junk" }, "timeline")).toBe(true);
|
||||
});
|
||||
test("both looks draw the shared pill header with the focus bar beneath it", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z",
|
||||
s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
for (const look of ["new", "classic"]) {
|
||||
s.prefs.timelineLook = look;
|
||||
const html = render(
|
||||
<TimelinePage
|
||||
state={s}
|
||||
focusBar={<p className="focus-marker" />}
|
||||
rows={readerEventRows(s, [sampleEvent], Date.parse(at))}
|
||||
now={Date.parse(at)}
|
||||
onAction={async () => ({ ok: true })}
|
||||
onOpen={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Show routines in own section");
|
||||
expect(html).toContain("Show end dates");
|
||||
expect(html).not.toContain("New look");
|
||||
expect(html.indexOf("focus-marker")).toBeGreaterThan(
|
||||
html.indexOf("Jump to today"),
|
||||
);
|
||||
}
|
||||
});
|
||||
test("the board opens back to the running starts, with now a quarter to half across", () => {
|
||||
// Starts far back: capped so now sits halfway.
|
||||
expect(openingLeft(1000, 0, 800)).toBe(600);
|
||||
// Starts just before now: now still a quarter across.
|
||||
expect(openingLeft(1000, 990, 800)).toBe(800);
|
||||
// In between: opens just before the start.
|
||||
expect(openingLeft(1000, 700, 800)).toBe(672);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderToStaticMarkup as render } from "react-dom/server";
|
||||
import { UpdatesPanel } from "../src/client/components/redesign/UpdatesPanel.tsx";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
test("codes and source age have distinct compact update rows, with no panel when empty", () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:test"),
|
||||
feed = {
|
||||
schemaVersion: 1 as const,
|
||||
generatedAt: at,
|
||||
events: [],
|
||||
sources: [],
|
||||
};
|
||||
const common = {
|
||||
state: s,
|
||||
feed,
|
||||
now: Date.parse(at),
|
||||
onSources: () => {},
|
||||
onAction: async () => ({ ok: true as const }),
|
||||
};
|
||||
expect(render(<UpdatesPanel {...common} />)).toBe("");
|
||||
const html = render(
|
||||
<UpdatesPanel
|
||||
{...common}
|
||||
codes={{
|
||||
schemaVersion: 1,
|
||||
sources: [],
|
||||
codes: [
|
||||
{
|
||||
id: "code",
|
||||
game: "genshin",
|
||||
code: "TEST",
|
||||
reward: null,
|
||||
expiresAt: null,
|
||||
firstObservedAt: at,
|
||||
sourceUrl: "https://example.com",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("New redeem codes");
|
||||
expect(html).not.toContain("New since you last looked");
|
||||
expect(html).not.toContain("Older schedules");
|
||||
});
|
||||
test("the source-age notice can be hidden for one, three or seven days", async () => {
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:test");
|
||||
const feed = {
|
||||
schemaVersion: 1 as const,
|
||||
generatedAt: at,
|
||||
events: [],
|
||||
sources: [
|
||||
{
|
||||
sourceId: "genshin-events",
|
||||
game: "genshin" as const,
|
||||
url: "https://example.com",
|
||||
lastSuccessAt: null,
|
||||
lastConfirmedAt: null,
|
||||
contentChangedAt: null,
|
||||
eventCount: 0,
|
||||
parsedCount: null,
|
||||
statesNoEvents: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const html = render(
|
||||
<UpdatesPanel
|
||||
state={s}
|
||||
feed={feed}
|
||||
now={Date.parse(at)}
|
||||
onSources={() => {}}
|
||||
onAction={async () => ({ ok: true as const })}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Event data may be out of date");
|
||||
expect(html).toContain('aria-label="Hide this notice"');
|
||||
for (const label of [">1 day<", ">3 days<", ">7 days<"])
|
||||
expect(html).toContain(label);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { parseRoute, routeHash } from "../src/client/state/route.ts";
|
||||
|
||||
test("pages round-trip through the address bar", () => {
|
||||
for (const route of [
|
||||
{ page: "soon" },
|
||||
{ page: "timeline" },
|
||||
{ page: "routines" },
|
||||
{ page: "archive" },
|
||||
{ page: "settings", settings: "changelog" },
|
||||
{ page: "settings", settings: "games", game: "genshin" },
|
||||
] as const)
|
||||
expect(parseRoute(routeHash(route))).toEqual(route);
|
||||
expect(routeHash({ page: "soon" })).toBe("#checklist");
|
||||
expect(routeHash({ page: "settings", settings: "about" })).toBe(
|
||||
"#settings/about",
|
||||
);
|
||||
});
|
||||
|
||||
test("an unknown or empty hash is no route, so the default view applies", () => {
|
||||
for (const hash of [
|
||||
"",
|
||||
"#",
|
||||
"#nowhere",
|
||||
"#settings/nope",
|
||||
"#settings/games/",
|
||||
])
|
||||
expect(parseRoute(hash)).toBeNull();
|
||||
// A settings hash without a section opens the default section.
|
||||
expect(parseRoute("#settings")).toEqual({
|
||||
page: "settings",
|
||||
settings: "appearance",
|
||||
});
|
||||
});
|
||||
|
||||
test("the old Default view address opens Appearance, where it moved", () => {
|
||||
expect(parseRoute("#settings/general")).toEqual({
|
||||
page: "settings",
|
||||
settings: "appearance",
|
||||
});
|
||||
});
|
||||
|
||||
test("the old Lists address opens Data & sources, where its options moved", () => {
|
||||
expect(parseRoute("#settings/lists")).toEqual({
|
||||
page: "settings",
|
||||
settings: "sources",
|
||||
});
|
||||
});
|
||||
|
||||
test("the games address without a game opens Appearance, where game order lives", () => {
|
||||
expect(parseRoute("#settings/games")).toEqual({
|
||||
page: "settings",
|
||||
settings: "appearance",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { periodOf } from "../src/shared/period.ts";
|
||||
import { TaskSchema, taskCaption } from "../src/shared/task.ts";
|
||||
import { migrateLegacy } from "../src/client/state/reader/migration.ts";
|
||||
import { routineRows } from "../src/client/state/reader/routines.ts";
|
||||
import { readerDayKey } from "../src/shared/readerClock.ts";
|
||||
|
||||
const base = {
|
||||
id: "mytask:abcdef1234",
|
||||
game: "genshin",
|
||||
title: "Log in",
|
||||
measure: { kind: "days", required: 1 },
|
||||
at: "2026-10-16T00:00:00.000Z",
|
||||
updatedAt: "2026-10-16T00:00:00.000Z",
|
||||
} as const;
|
||||
|
||||
test("a daily period is the one game-day", () => {
|
||||
expect(periodOf({ unit: "day" }, "2026-10-17")).toEqual({
|
||||
start: "2026-10-17",
|
||||
days: ["2026-10-17"],
|
||||
});
|
||||
expect(taskCaption({ period: { unit: "day" }, measure: base.measure })).toBe(
|
||||
"every day · tick it off",
|
||||
);
|
||||
});
|
||||
|
||||
test("own routines keep a reset hour, a note and a web link, and nothing else", () => {
|
||||
const ok = TaskSchema.safeParse({
|
||||
...base,
|
||||
period: { unit: "day" },
|
||||
resetHour: 20,
|
||||
note: "Bring a healer",
|
||||
link: "https://example.com/guide",
|
||||
});
|
||||
expect(ok.success).toBe(true);
|
||||
for (const bad of [
|
||||
{ link: "javascript:alert(1)" },
|
||||
{ link: "ftp://example.com" },
|
||||
{ resetHour: 24 },
|
||||
{ resetHour: 1.5 },
|
||||
])
|
||||
expect(
|
||||
TaskSchema.safeParse({ ...base, period: { unit: "day" }, ...bad })
|
||||
.success,
|
||||
).toBe(false);
|
||||
// Older records, without the new fields, still parse.
|
||||
expect(
|
||||
TaskSchema.safeParse({ ...base, period: { unit: "week", startsOn: 1 } })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("a custom reset hour moves only that routine's day boundary", () => {
|
||||
const at = "2026-10-16T12:00:00.000Z";
|
||||
const s = migrateLegacy({}, ["genshin"], at, () => "account:one");
|
||||
s.tasks[base.id] = { ...base, period: { unit: "day" }, resetHour: 20 };
|
||||
// 10:00 UTC on the 16th is 11:00 in Europe's server time (UTC+1): the
|
||||
// game's 04:00 reset has opened the 16th, but a 20:00 routine's day labelled
|
||||
// the 16th has not begun yet.
|
||||
const now = Date.parse("2026-10-16T10:00:00.000Z");
|
||||
const rows = routineRows(s, "account:one", now);
|
||||
const own = rows.find((r) => r.id === base.id)!;
|
||||
const daily = rows.find((r) => r.id === "dailies:genshin")!;
|
||||
expect(own.cadence).toBe("day");
|
||||
expect(own.clock.resetHour).toBe(20);
|
||||
expect(own.clock.revision).toBe(daily.clock.revision);
|
||||
expect(readerDayKey(now, daily.clock)).toBe("2026-10-16");
|
||||
expect(readerDayKey(now, own.clock)).toBe("2026-10-15");
|
||||
});
|
||||
Reference in New Issue
Block a user