diff --git a/AGENTS.md b/AGENTS.md index 13117e5..44538ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -789,6 +789,11 @@ source CI can refresh. Four things to know about it: label so both sources mint one ID, and `src/client/state/retiredIds.ts` moves readers' marks off the two IDs that changed. The general lesson: **a second source needs its whole overlap with the first read row by row, not counted** — a duplicate looks exactly like a net-new event in a tally. +- **A fusion carries readers' ticks with it, unaided.** Merge lists every id it absorbed on the + survivor (`mergedIds`), the feed ships it, and the client moves marks off those ids when a feed + arrives (`aliasesFrom`, `retiredIds.ts`). So a new merge rule, or a copy that starts matching + next week, needs no hand-edited table. `RETIRED_IDS` is only for an id a *parser* change moved — + the old id is never produced again, so no feed can name it. - **The 336-day event raised the duration ceiling to 365 days.** Genshin's anniversary 5-star selection runs 336 days (`2025-10-22` → `2026-09-23`), correctly dated on this wiki. This motivated raising the sanity ceiling in `test/adapters/game8.test.ts` and `docs/INGESTION.md` from 180 to 365 days. diff --git a/docs/DATA-MODEL.md b/docs/DATA-MODEL.md index bf35bdd..02c6e06 100644 --- a/docs/DATA-MODEL.md +++ b/docs/DATA-MODEL.md @@ -60,6 +60,10 @@ export const GachaEvent = z.object({ version: z.number().int().positive(), firstSeenAt: z.string().datetime(), updatedAt: z.string().datetime(), + + // Other sources' copies merge folded into this one; the client moves marks + // off them. Optional — see § ID stability. + mergedIds: z.array(z.string()).optional(), }); export type GachaEvent = z.infer; @@ -119,7 +123,15 @@ migration that reads the old keys and remaps them, and keep that migration for a holds an explicit table of IDs the feed stopped publishing and what each became — first used on 2026-10-14, when Game8's Genshin parser stopped keeping `Battle Pass -` in front of a pass's name (the label had put every pass on the calendar twice beside Fandom's bare name). On every load and -every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one: +every import, `progress`, `daily` and `ignored` entries under a retired ID **move** to the new one. + +**A merge that starts fusing two published IDs retires one of them too — and the feed says so.** +`GachaEvent.mergedIds` lists every other source's copy merge folded into an event. When a feed +arrives, `aliasesFrom` turns those into the same kind of move, skipping any id the feed still +publishes as its own row (a pair split again keeps each row's marks), so no table is edited by hand. +`RETIRED_IDS` stays only for parser changes, whose old ids no feed will ever name again. The field is +optional: an older cached feed lacks it and validates, and an older bundle's non-strict parse drops +it unread. Both paths follow the same rules: - **A move inside the same storage key, not a copy, and not a deleted key.** The mark survives under the ID the feed now uses. A copy left under the old ID would be carried forward again on each load @@ -129,7 +141,14 @@ every import, `progress`, `daily` and `ignored` entries under a retired ID **mov held, because both rows were the reader's own. - **An unreadable entry is left where it is.** These run in `useState` initialisers, where a throw is a blank screen. -- **A table, never a pattern**, and kept until at least 2027-10-14. +- **A table, never a pattern**, and kept until at least 2027-10-14 — for `RETIRED_IDS`; the feed's + table is rebuilt from every feed. +- **A wrong fusion takes the tick with it, and nothing undoes that.** A move keeps no record of + where a mark began, so if merge ever fuses two different events, the absorbed row's tick lands on + the survivor and stays there if a later rule splits them again (reviewed 2026-10-14, accepted as + the price of not resurrecting cleared marks). The only defence is that merge rules stay strict — + which is why dates alone never pair two events (`docs/INGESTION.md` § Stage 3). An import is + carried through the same two tables on its way in. The date suffix disambiguates reruns of the same event. Title is slugified from the *source's* title, so a wiki renaming an event creates a new ID — reconciliation detects this as a near-match diff --git a/docs/INGESTION.md b/docs/INGESTION.md index 751361b..0028646 100644 --- a/docs/INGESTION.md +++ b/docs/INGESTION.md @@ -486,6 +486,10 @@ Only meaningful when a game has more than one source; a single-source game passe across sources — an identical id is the same row seen twice, whatever it is called. 2. **Near match** — **different sources**, same game, title similarity ≥ 0.80, starts within 24h — → same event under different titles; keep the higher-confidence copy. + + **The survivor lists what it absorbed** in `mergedIds`, and the client moves readers' marks off + those ids when the feed arrives — so a rule that starts fusing two published ids costs nobody a + tick, with no table to edit (`docs/DATA-MODEL.md` § ID stability). 3. **Otherwise** → distinct events; keep both. Title similarity alone would merge a rerun with its original, since reruns reuse the name. The diff --git a/src/client/App.tsx b/src/client/App.tsx index a63d024..57226e3 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -12,10 +12,16 @@ import { Colophon } from "./components/Colophon.tsx"; import { Legend } from "./components/Legend.tsx"; import { Toast } from "./components/Toast.tsx"; import { UpdateNotice } from "./components/UpdateNotice.tsx"; +import { + aliasesFrom, + carryDaily, + carryMarks, + carryProgress, +} from "./state/retiredIds.ts"; import { KEYS } from "./state/storage.ts"; import { useAppUpdate } from "./state/useAppUpdate.ts"; -import { useMarkSet } from "./state/useMarkSet.ts"; -import { useProgress } from "./state/useProgress.ts"; +import { useMarkSet, type Marks } from "./state/useMarkSet.ts"; +import { useProgress, type ProgressMap } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useTasks } from "./state/useTasks.ts"; import { useAmounts } from "./state/useAmounts.ts"; @@ -298,6 +304,31 @@ export function App() { return () => ac.abort(); }, []); + // Merge may have folded another source's copy into a row since this reader + // last looked, and that copy's id may hold their ticks. The feed names every + // id it absorbed, so the marks follow with nobody editing a table + // (docs/DATA-MODEL.md § ID stability). + const feedEvents = state.status === "ready" ? state.feed.events : null; + const feedCarry = useMemo( + () => (feedEvents === null ? {} : aliasesFrom(feedEvents)), + [feedEvents], + ); + const carryProgressIds = prog.carry; + const carryDailyIds = daily.carry; + const carryIgnoredIds = ignored.carry; + useEffect(() => { + if (Object.keys(feedCarry).length === 0) return; + carryProgressIds(feedCarry); + carryDailyIds(feedCarry); + carryIgnoredIds(feedCarry); + }, [feedCarry, carryProgressIds, carryDailyIds, carryIgnoredIds]); + // An export written before a fusion still names the absorbed id, and the + // effect above has already run for this feed — so an import takes the same + // table on its way in. + const importProgressMerge = (c: ProgressMap) => prog.merge(carryProgress(c, feedCarry)); + const importDailyMerge = (c: DailyLogMap) => daily.merge(carryDaily(c, feedCarry)); + const importIgnoredMerge = (c: Marks) => ignored.merge(carryMarks(c, feedCarry)); + const allRows = useMemo(() => { if (state.status !== "ready") return []; // The reader's own events are events. They sort, filter, focus, expire and @@ -840,9 +871,9 @@ export function App() { onImport={(file) => void importProgress( file, - prog.merge, - daily.merge, - ignored.merge, + importProgressMerge, + importDailyMerge, + importIgnoredMerge, custom.merge, mergeExtra, ) @@ -858,9 +889,9 @@ export function App() { onImportAll={(file) => void importProgress( file, - prog.merge, - daily.merge, - ignored.merge, + importProgressMerge, + importDailyMerge, + importIgnoredMerge, custom.merge, mergeExtra, restorePrefs, diff --git a/src/client/state/retiredIds.ts b/src/client/state/retiredIds.ts index 45b7969..6972ea3 100644 --- a/src/client/state/retiredIds.ts +++ b/src/client/state/retiredIds.ts @@ -6,7 +6,7 @@ import { mergeProgress, type ProgressMap } from "./useProgress.ts"; export type CarryTable = Readonly>; /** - * Event ids the feed no longer publishes, and the id each one became. + * Event ids a *parser* change stopped publishing, and the id each one became. * * An event id is a localStorage key (AGENTS.md § Event IDs are localStorage * keys), so an id that moves takes the reader's marks with it unless they are @@ -15,6 +15,11 @@ export type CarryTable = Readonly>; * published the bare name, and the two ids put every pass on the calendar * twice (docs/superpowers/specs/2026-10-14-genshin-duplicates-design.md). * + * Only a parser change belongs here. An id that *merge* folds into another is + * named by the feed itself (`GachaEvent.mergedIds`) and carried by + * `aliasesFrom` with nobody editing anything; a parser change cannot be, + * because the old id is never produced again for any feed to mention. + * * An explicit table, never a pattern: the battle passes are the only two such * ids that ever shipped (git history of snapshots/ and fixtures/), and a * pattern would also catch an id nobody retired. Keep it until at least @@ -26,6 +31,29 @@ export const RETIRED_IDS: CarryTable = { "genshin:battle-pass-moontrace:2026-09-23": "genshin:moontrace:2026-09-23", }; +/** + * What the feed says merge absorbed, as a carry table. + * + * Every id an event lists in `mergedIds` was another source's copy of it, and + * may already hold a reader's marks from a day it was published alone. Two + * exclusions: an id the feed still publishes as its own row — should a later + * feed split a pair again, each row keeps its own marks — and an event naming + * itself, which would be a no-op at best. + */ +export function aliasesFrom( + events: ReadonlyArray<{ id: string; mergedIds?: readonly string[] | undefined }>, +): CarryTable { + const published = new Set(events.map((e) => e.id)); + const table: Record = {}; + for (const event of events) { + for (const from of event.mergedIds ?? []) { + if (from === event.id || published.has(from)) continue; + table[from] = event.id; + } + } + return table; +} + /** * Move each entry under an old id in `table` to its replacement. * diff --git a/src/client/state/useDailyLog.ts b/src/client/state/useDailyLog.ts index a9249d1..4d8d5fc 100644 --- a/src/client/state/useDailyLog.ts +++ b/src/client/state/useDailyLog.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { carryDaily } from "./retiredIds.ts"; +import { carryDaily, type CarryTable } from "./retiredIds.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; export interface DailyLog { @@ -53,7 +53,12 @@ export function useDailyLog() { setLogs((prev) => mergeDailyLogs(prev, incoming)); }, []); - return { logs, toggleDay, daysFor, merge }; + /** Move logs off ids the feed says were folded into others. */ + const carry = useCallback((table: CarryTable) => { + setLogs((prev) => carryDaily(prev, table)); + }, []); + + return { logs, toggleDay, daysFor, merge, carry }; } /** diff --git a/src/client/state/useMarkSet.ts b/src/client/state/useMarkSet.ts index 016fba7..4e3a97e 100644 --- a/src/client/state/useMarkSet.ts +++ b/src/client/state/useMarkSet.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { carryMarks } from "./retiredIds.ts"; +import { carryMarks, type CarryTable } from "./retiredIds.ts"; import { readJson, writeJson } from "./storage.ts"; export interface Mark { @@ -41,7 +41,12 @@ export function useMarkSet(storageKey: string) { setMarks((prev) => mergeMarks(prev, incoming)); }, []); - return { marks, toggle, merge, clear }; + /** Move marks off ids the feed says were folded into others. */ + const carry = useCallback((table: CarryTable) => { + setMarks((prev) => carryMarks(prev, table)); + }, []); + + return { marks, toggle, merge, clear, carry }; } /** diff --git a/src/client/state/useProgress.ts b/src/client/state/useProgress.ts index b8f40db..022744e 100644 --- a/src/client/state/useProgress.ts +++ b/src/client/state/useProgress.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import type { Effort } from "../../shared/effort.ts"; import type { Goal } from "../../shared/goal.ts"; -import { carryProgress } from "./retiredIds.ts"; +import { carryProgress, type CarryTable } from "./retiredIds.ts"; import { KEYS, readJson, writeJson } from "./storage.ts"; import type { Marks } from "./useMarkSet.ts"; @@ -211,6 +211,11 @@ export function useProgress() { setProgress((prev) => mergeProgress(prev, incoming)); }, []); + /** Move records off ids the feed says were folded into others. */ + const carry = useCallback((table: CarryTable) => { + setProgress((prev) => carryProgress(prev, table)); + }, []); + return { progress, patch, @@ -220,5 +225,6 @@ export function useProgress() { setEffort, setNote, merge, + carry, }; } diff --git a/src/ingest/merge.ts b/src/ingest/merge.ts index 5ca823a..c267340 100644 --- a/src/ingest/merge.ts +++ b/src/ingest/merge.ts @@ -83,10 +83,11 @@ export function mergeEvents( if (existing === undefined) continue; const conflict = findConflict(existing, incoming, toleranceHours); - const [winner, loser] = + const [ranked, loser] = incoming.confidence > existing.confidence ? ([incoming, existing] as const) : ([existing, incoming] as const); + const winner = absorb(ranked, loser); if (conflict !== null) { // Independent sources disagree on when this ends. Do not average, do not @@ -116,6 +117,24 @@ export function mergeEvents( return { events: kept, conflicts }; } +/** + * The winner, carrying every id the fusion took off the calendar. + * + * An id merge drops may already be on readers' devices as a localStorage key — + * the copy fused today may have been published alone yesterday, before a rule + * learned to pair it. The feed says which ids were folded into which, and the + * client moves marks across on its own (`src/client/state/retiredIds.ts`), so a + * new fusion never needs a hand-edited table to keep a reader's ticks. + */ +function absorb(winner: GachaEvent, loser: GachaEvent): GachaEvent { + const ids = [ + ...(winner.mergedIds ?? []), + ...(loser.id === winner.id ? [] : [loser.id]), + ...(loser.mergedIds ?? []), + ].filter((id, i, all) => id !== winner.id && all.indexOf(id) === i); + return ids.length === 0 ? winner : { ...winner, mergedIds: ids }; +} + function isSameEvent( a: GachaEvent, b: GachaEvent, diff --git a/src/shared/schema.ts b/src/shared/schema.ts index 5d4258e..2927e69 100644 --- a/src/shared/schema.ts +++ b/src/shared/schema.ts @@ -76,6 +76,15 @@ export const GachaEvent = z version: z.number().int().positive(), firstSeenAt: z.string().datetime(), updatedAt: z.string().datetime(), + + /** + * Ids of other sources' copies merge folded into this one. They may already + * be readers' localStorage keys, so the client moves marks from them to + * `id` (docs/DATA-MODEL.md § ID stability). Optional: absent on every + * single-source event and on any feed built before 2026-10-14, and an older + * client's non-strict parse drops it unread. + */ + mergedIds: z.array(z.string()).optional(), }) // These invariants are cheap here and prevent a whole class of wrong-date bugs // reaching the feed. See docs/INGESTION.md § Stage 4. diff --git a/test/feed.test.ts b/test/feed.test.ts index c372683..bdafc3c 100644 --- a/test/feed.test.ts +++ b/test/feed.test.ts @@ -8,7 +8,11 @@ import { SourceHealth, } from "../src/shared/feed.ts"; import { sourceHealth } from "../src/ingest/health.ts"; -import type { GachaEvent, GameId } from "../src/shared/schema.ts"; +import { + GachaEvent as GachaEventSchema, + type GachaEvent, + type GameId, +} from "../src/shared/schema.ts"; /** * Freshness disclosure (PRD F7). @@ -430,3 +434,28 @@ describe("recording a source's health at build time", () => { expect(health.lastConfirmedAt).toBe(NEW_CONTENT); }); }); + +describe("mergedIds in the feed", () => { + // The service worker serves the last feed it downloaded, and an open tab may + // run a bundle from before this field existed — both directions must parse. + const sample = async () => + ( + (await Bun.file( + "fixtures/genshin/game8-events-2026-08-14.expected.json", + ).json()) as unknown[] + )[0] as Record; + + test("an event without it still validates", async () => { + expect(GachaEventSchema.safeParse(await sample()).success).toBe(true); + }); + + test("an event with it keeps it", async () => { + const parsed = GachaEventSchema.safeParse({ + ...(await sample()), + mergedIds: ["genshin:other:2026-08-12"], + }); + expect(parsed.success && parsed.data.mergedIds).toEqual([ + "genshin:other:2026-08-12", + ]); + }); +}); diff --git a/test/merge.test.ts b/test/merge.test.ts index b8d10e9..69afe8e 100644 --- a/test/merge.test.ts +++ b/test/merge.test.ts @@ -190,6 +190,30 @@ describe("merging two sources for one game", () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]?.field).toBe("endsAt"); }); + + test("the survivor lists every id it absorbed", () => { + const a = event({ id: "genshin:stygian-onslaught:2026-08-12", title: "Stygian Onslaught", sourceId: "s-a" }); + const b = event({ id: "genshin:stygian-onslaught-event:2026-08-12", title: "Stygian Onslaught Event", sourceId: "s-b" }); + const c = event({ id: "genshin:stygian-onslaught-trial:2026-08-12", title: "Stygian Onslaught Trial", sourceId: "s-c" }); + const { events } = mergeEvents([[a], [b], [c]]); + expect(events).toHaveLength(1); + expect(events[0]?.mergedIds).toEqual([b.id, c.id]); + }); + + test("a higher-confidence newcomer inherits what the old copy absorbed", () => { + const a = event({ id: "genshin:x-y:2026-08-12", title: "X Y", sourceId: "s-a", confidence: 0.8 }); + const b = event({ id: "genshin:x-y-z:2026-08-12", title: "X Y Z", sourceId: "s-b", confidence: 0.8 }); + const c = event({ id: "genshin:x-y-w:2026-08-12", title: "X Y W", sourceId: "s-c", confidence: 0.95 }); + const { events } = mergeEvents([[a], [b], [c]]); + expect(events.map((e) => e.id)).toEqual([c.id]); + expect([...(events[0]?.mergedIds ?? [])].sort()).toEqual([a.id, b.id].sort()); + }); + + test("an id seen twice is not an absorbed id", () => { + const a = event({ sourceId: "s-a" }); + const { events } = mergeEvents([[a], [{ ...a, sourceId: "s-b" }]]); + expect(events[0]?.mergedIds).toBeUndefined(); + }); }); describe("near matches within one source", () => { diff --git a/test/retired-ids.test.ts b/test/retired-ids.test.ts index 376759b..e306018 100644 --- a/test/retired-ids.test.ts +++ b/test/retired-ids.test.ts @@ -2,53 +2,66 @@ import { describe, expect, test } from "bun:test"; import { parseImportData } from "../src/client/state/export.ts"; import { RETIRED_IDS, + aliasesFrom, carryForward, carryDaily, carryMarks, carryProgress, } from "../src/client/state/retiredIds.ts"; -const OLD = "genshin:old-name:2026-09-23"; -const NEW = "genshin:new-name:2026-09-23"; -const T = { [OLD]: NEW }; +const OLD = "genshin:battle-pass-moontrace:2026-09-23"; +const NEW = "genshin:moontrace:2026-09-23"; + +describe("RETIRED_IDS", () => { + test("names the two battle passes Game8 minted with their label", () => { + expect(RETIRED_IDS[OLD]).toBe(NEW); + expect(RETIRED_IDS["genshin:battle-pass-frostfarer:2026-08-12"]).toBe( + "genshin:frostfarer:2026-08-12", + ); + }); + + test("holds only what a parser moved; merges arrive through the feed", () => { + expect(Object.keys(RETIRED_IDS)).toHaveLength(2); + }); + + test("never retires an id onto another retired id", () => { + for (const to of Object.values(RETIRED_IDS)) { + expect(RETIRED_IDS[to]).toBeUndefined(); + } + }); +}); describe("carryForward", () => { test("moves an entry to the id the feed now uses", () => { - const out = carryForward({ [OLD]: { n: 1 } }, (a) => a, T); + const out = carryForward({ [OLD]: { n: 1 } }, (a) => a); expect(out).toEqual({ [NEW]: { n: 1 } }); }); test("leaves ids nobody retired alone, and returns the same object", () => { const map = { "genshin:stygian-onslaught:2026-09-30": { n: 1 } }; - expect(carryForward(map, (a) => a, T)).toBe(map); + expect(carryForward(map, (a) => a)).toBe(map); }); test("a corrupt store or entry never takes the app down", () => { // `readJson` returns a parsed `null` as it found it, and these run inside // useState initialisers, where a throw is a blank screen. - expect(carryMarks(null as never, T)).toBe(null as never); - const out = carryMarks( - { [OLD]: null as never, [NEW]: { at: "2026-09-24T00:00:00.000Z" } }, - T, - ); + expect(carryMarks(null as never)).toBe(null as never); + const out = carryMarks({ [OLD]: null as never, [NEW]: { at: "2026-09-24T00:00:00.000Z" } }); expect(out[NEW]).toEqual({ at: "2026-09-24T00:00:00.000Z" }); }); test("is idempotent", () => { - const once = carryForward({ [OLD]: { n: 1 }, x: { n: 2 } }, (a) => a, T); - expect(carryForward(once, (a) => a, T)).toEqual(once); + const once = carryForward({ [OLD]: { n: 1 }, x: { n: 2 } }, (a) => a); + expect(carryForward(once, (a) => a)).toEqual(once); }); }); -describe("when the reader marked both rows", () => { +describe("when the reader marked both duplicate rows", () => { test("progress keeps whichever was touched last", () => { - const out = carryProgress( - { - [OLD]: { status: "done", at: "2026-10-02T00:00:00.000Z" }, - [NEW]: { status: "doing", at: "2026-10-01T00:00:00.000Z" }, - }, - T, - ); + const out = carryProgress({ + [OLD]: { status: "done", at: "2026-10-02T00:00:00.000Z" }, + [NEW]: { status: "doing", at: "2026-10-01T00:00:00.000Z" }, + }); expect(out).toEqual({ [NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" }, }); @@ -57,26 +70,20 @@ describe("when the reader marked both rows", () => { test("progress keeps a field only the older row held", () => { // Both rows are the reader's own, so the later one wins each field it // sets — but a note or a goal it never had is not something they removed. - const out = carryProgress( - { - [OLD]: { status: "doing", note: "farm weekly", at: "2026-09-25T00:00:00.000Z" }, - [NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" }, - }, - T, - ); + const out = carryProgress({ + [OLD]: { status: "doing", note: "farm weekly", at: "2026-09-25T00:00:00.000Z" }, + [NEW]: { status: "done", at: "2026-10-02T00:00:00.000Z" }, + }); expect(out).toEqual({ [NEW]: { status: "done", note: "farm weekly", at: "2026-10-02T00:00:00.000Z" }, }); }); test("the daily log keeps every day either row was ticked", () => { - const out = carryDaily( - { - [OLD]: { days: ["2026-09-24", "2026-09-26"], at: "2026-09-26T00:00:00.000Z" }, - [NEW]: { days: ["2026-09-25", "2026-09-26"], at: "2026-09-25T00:00:00.000Z" }, - }, - T, - ); + const out = carryDaily({ + [OLD]: { days: ["2026-09-24", "2026-09-26"], at: "2026-09-26T00:00:00.000Z" }, + [NEW]: { days: ["2026-09-25", "2026-09-26"], at: "2026-09-25T00:00:00.000Z" }, + }); expect(out).toEqual({ [NEW]: { days: ["2026-09-24", "2026-09-25", "2026-09-26"], @@ -86,58 +93,73 @@ describe("when the reader marked both rows", () => { }); test("an ignore keeps the earlier mark", () => { - const out = carryMarks( - { - [OLD]: { at: "2026-09-24T00:00:00.000Z" }, - [NEW]: { at: "2026-09-30T00:00:00.000Z" }, - }, - T, - ); + const out = carryMarks({ + [OLD]: { at: "2026-09-24T00:00:00.000Z" }, + [NEW]: { at: "2026-09-30T00:00:00.000Z" }, + }); expect(out).toEqual({ [NEW]: { at: "2026-09-24T00:00:00.000Z" } }); }); }); -const PASS = "genshin:battle-pass-moontrace:2026-09-23"; -const BARE = "genshin:moontrace:2026-09-23"; - -describe("RETIRED_IDS", () => { - test("names the two battle passes Game8 minted with their label", () => { - expect(RETIRED_IDS[PASS]).toBe(BARE); - expect(RETIRED_IDS["genshin:battle-pass-frostfarer:2026-08-12"]).toBe( - "genshin:frostfarer:2026-08-12", - ); - }); - - test("never retires an id onto another retired id", () => { - for (const to of Object.values(RETIRED_IDS)) { - expect(RETIRED_IDS[to]).toBeUndefined(); - } - }); - - test("is the table a load uses when none is given", () => { - const out = carryMarks({ [PASS]: { at: "2026-09-24T00:00:00.000Z" } }); - expect(Object.keys(out)).toEqual([BARE]); - }); -}); - describe("an export made before the ids moved", () => { test("imports onto the new ids", () => { const data = parseImportData({ format: "gacha-tracker-export", - progress: { [PASS]: { status: "done", at: "2026-10-01T00:00:00.000Z" } }, - daily: { [PASS]: { days: ["2026-09-24"], at: "2026-09-24T00:00:00.000Z" } }, - ignored: { [PASS]: { at: "2026-09-24T00:00:00.000Z" } }, + progress: { [OLD]: { status: "done", at: "2026-10-01T00:00:00.000Z" } }, + daily: { [OLD]: { days: ["2026-09-24"], at: "2026-09-24T00:00:00.000Z" } }, + ignored: { [OLD]: { at: "2026-09-24T00:00:00.000Z" } }, }); - expect(Object.keys(data?.progress ?? {})).toEqual([BARE]); - expect(Object.keys(data?.daily ?? {})).toEqual([BARE]); - expect(Object.keys(data?.ignored ?? {})).toEqual([BARE]); + expect(Object.keys(data?.progress ?? {})).toEqual([NEW]); + expect(Object.keys(data?.daily ?? {})).toEqual([NEW]); + expect(Object.keys(data?.ignored ?? {})).toEqual([NEW]); }); test("so does one from before progress existed", () => { const data = parseImportData({ format: "gacha-tracker-export", - completions: { [PASS]: { at: "2026-10-01T00:00:00.000Z" } }, + completions: { [OLD]: { at: "2026-10-01T00:00:00.000Z" } }, }); - expect(data?.progress?.[BARE]?.status).toBe("done"); + expect(data?.progress?.[NEW]?.status).toBe("done"); + }); +}); + +describe("ids the feed says merge absorbed", () => { + const LIMITED = "genshin:free-limited-5-star-anniversary-selector:2026-09-23"; + const TABLETOP = "genshin:tabletop-troupe-a-gathering-on-adventures-eve:2026-09-23"; + + test("become carry-forward pairs onto the surviving id", () => { + expect(aliasesFrom([{ id: LIMITED, mergedIds: [TABLETOP] }])).toEqual({ + [TABLETOP]: LIMITED, + }); + }); + + test("never move a mark off an id the feed still publishes", () => { + // Should a later feed split the pair again, both rows are back on the + // calendar and each keeps its own marks. + const table = aliasesFrom([ + { id: LIMITED, mergedIds: [TABLETOP] }, + { id: TABLETOP }, + ]); + expect(table).toEqual({}); + }); + + test("ignore an event that lists itself", () => { + expect(aliasesFrom([{ id: LIMITED, mergedIds: [LIMITED] }])).toEqual({}); + }); + + test("move a Fandom-selector tick onto Game8's row", () => { + const table = aliasesFrom([{ id: LIMITED, mergedIds: [TABLETOP] }]); + const out = carryProgress( + { [TABLETOP]: { status: "done", at: "2026-10-01T00:00:00.000Z" } }, + table, + ); + expect(out).toEqual({ + [LIMITED]: { status: "done", at: "2026-10-01T00:00:00.000Z" }, + }); + }); + + test("a table passed in replaces the hand table for that call", () => { + const out = carryMarks({ [OLD]: { at: "2026-09-24T00:00:00.000Z" } }, {}); + expect(Object.keys(out)).toEqual([OLD]); }); });