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]>
218 lines
6.6 KiB
TypeScript
218 lines
6.6 KiB
TypeScript
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,
|
|
});
|
|
});
|