import { describe, expect, test } from "bun:test"; import { mergeEvents, titleExtends, titleSimilarity, } from "../src/ingest/merge.ts"; import { recordedSame } from "../src/ingest/aliases.ts"; import type { GachaEvent } from "../src/shared/schema.ts"; const NOW = "2026-08-14T00:00:00.000Z"; function event(overrides: Partial = {}): GachaEvent { return { id: "genshin:test-event:2026-08-12", game: "genshin", title: "Test Event", type: "other", summary: null, startsAt: "2026-08-12T00:00:00.000Z", startPrecision: "day", endsAt: "2026-08-24T00:00:00.000Z", endPrecision: "day", regionScoped: false, regionEnds: null, sourceUrl: "https://example.test/a", sourceId: "source-a", status: "published", confidence: 0.9, extractionMethod: "parser", version: 1, firstSeenAt: NOW, updatedAt: NOW, ...overrides, }; } describe("mergeEvents", () => { test("keeps distinct events from different sources", () => { const a = event({ id: "genshin:a:2026-08-12", title: "Alpha" }); const b = event({ id: "genshin:b:2026-09-01", title: "Beta", startsAt: "2026-09-01T00:00:00.000Z", endsAt: "2026-09-10T00:00:00.000Z", sourceId: "source-b", }); const { events, conflicts } = mergeEvents([[a], [b]]); expect(events).toHaveLength(2); expect(conflicts).toHaveLength(0); }); test("collapses the same event seen by two sources", () => { const a = event({ sourceId: "source-a", confidence: 0.85 }); const b = event({ sourceId: "source-b", confidence: 0.9 }); const { events } = mergeEvents([[a], [b]]); expect(events).toHaveLength(1); }); test("raises confidence when an independent source corroborates", () => { const a = event({ sourceId: "source-a", confidence: 0.85 }); const b = event({ sourceId: "source-b", confidence: 0.85 }); const { events } = mergeEvents([[a], [b]]); // Two sources independently agreeing is stronger evidence than one. expect(events[0]?.confidence).toBeCloseTo(0.95, 5); }); test("does not corroborate a duplicate from the same source", () => { const a = event({ sourceId: "source-a", confidence: 0.85 }); const { events } = mergeEvents([[a, { ...a }]]); expect(events).toHaveLength(1); expect(events[0]?.confidence).toBeCloseTo(0.85, 5); }); test("matches the same event under slightly different titles", () => { const a = event({ title: "Stygian Onslaught", sourceId: "source-a" }); const b = event({ id: "genshin:stygian-onslaught-event:2026-08-12", title: "Stygian Onslaught Event", sourceId: "source-b", }); const { events } = mergeEvents([[a], [b]]); expect(events).toHaveLength(1); }); test("flags an end-date disagreement instead of picking silently", () => { const a = event({ sourceId: "source-a", confidence: 0.9 }); const b = event({ sourceId: "source-b", confidence: 0.8, endsAt: "2026-08-28T00:00:00.000Z", // 4 days later }); const { events, conflicts } = mergeEvents([[a], [b]]); expect(events).toHaveLength(1); expect(conflicts).toHaveLength(1); expect(conflicts[0]?.field).toBe("endsAt"); expect(conflicts[0]?.deltaHours).toBe(96); // The conflict is surfaced, not averaged away, and confidence is NOT // bumped — disagreement is the opposite of corroboration. expect(events[0]?.confidence).toBeCloseTo(0.9, 5); }); test("treats a same-name rerun months later as a separate event", () => { const a = event({ title: "Windblume Festival" }); const b = event({ id: "genshin:windblume-festival:2027-03-01", title: "Windblume Festival", startsAt: "2027-03-01T00:00:00.000Z", endsAt: "2027-03-20T00:00:00.000Z", sourceId: "source-b", }); const { events } = mergeEvents([[a], [b]]); expect(events).toHaveLength(2); }); test("returns events sorted by start date", () => { const late = event({ id: "genshin:late:2026-09-01", title: "Late", startsAt: "2026-09-01T00:00:00.000Z", endsAt: "2026-09-05T00:00:00.000Z", }); const early = event({ id: "genshin:early:2026-08-01", title: "Early", startsAt: "2026-08-01T00:00:00.000Z" }); const { events } = mergeEvents([[late], [early]]); expect(events.map((e) => e.title)).toEqual(["Early", "Late"]); }); }); describe("titleSimilarity", () => { test("identical titles score 1", () => { expect(titleSimilarity("Stygian Onslaught", "Stygian Onslaught")).toBe(1); }); test("ignores case and punctuation", () => { expect(titleSimilarity("Gold Clash!", "gold clash")).toBe(1); }); test("unrelated titles score low", () => { expect(titleSimilarity("Gold Clash", "Fishing Frenzy")).toBeLessThan(0.3); }); }); describe("titleExtends", () => { test("matches a title with an appended qualifier", () => { // Real case: Game8 says "Bedazzling Dawnstar Sign-In", wiki.gg says // "Bedazzling Dawnstar". Token overlap scores 0.67 — under any safe // threshold — but it is plainly one event. expect(titleExtends("Bedazzling Dawnstar", "Bedazzling Dawnstar Sign-In")).toBe(true); }); test("refuses a single shared word", () => { // A one-word title would otherwise swallow everything containing it. expect(titleExtends("Rerun", "Overflowing Abundance Rerun")).toBe(false); }); test("refuses a shared-word subset that is not a prefix", () => { // Subset matching would fuse these; they are different events. expect(titleExtends("Gold Clash", "Gold Rush Clash Royale")).toBe(false); }); test("refuses two titles of equal length", () => { expect(titleExtends("Gold Clash", "Gold Rush")).toBe(false); }); test("matches a title with a category label in front", () => { // Game8 heads Genshin's pass `Battle Pass - Moontrace` where Fandom says // `Moontrace`: the same qualifier, written at the other end. expect(titleExtends("Frozen Wilds", "Battle Pass - Frozen Wilds")).toBe(true); }); test("refuses a one-word title under a label, as at the end", () => { // The floor holds in both directions. `Moontrace` itself is made one ID by // the Game8 parser dropping the label, not by loosening this. expect(titleExtends("Moontrace", "Battle Pass - Moontrace")).toBe(false); }); test("refuses shared words in the middle", () => { expect(titleExtends("Gold Clash", "Royal Gold Clash Rush")).toBe(false); }); }); describe("merging two sources for one game", () => { test("collapses an appended-qualifier duplicate and flags the date gap", () => { const g8 = event({ id: "endfield:bedazzling-dawnstar-sign-in:2026-08-09", game: "endfield", title: "Bedazzling Dawnstar Sign-In", startsAt: "2026-08-09T00:00:00.000Z", endsAt: "2026-08-30T00:00:00.000Z", sourceId: "endfield-game8-events", confidence: 0.85, }); const wiki = event({ id: "endfield:bedazzling-dawnstar:2026-08-09", game: "endfield", title: "Bedazzling Dawnstar", startsAt: "2026-08-09T04:00:00.000Z", endsAt: "2026-09-01T22:00:00.000Z", sourceId: "endfield-wikigg-events", confidence: 0.95, }); const { events, conflicts } = mergeEvents([[g8], [wiki]]); expect(events).toHaveLength(1); // The better-sourced copy wins, and the disagreement is surfaced rather // than averaged into a date neither source states. expect(events[0]?.sourceId).toBe("endfield-wikigg-events"); expect(conflicts).toHaveLength(1); expect(conflicts[0]?.field).toBe("endsAt"); }); test("fuses a pair when one summary names the other's title", () => { // Game8 and Fandom name Genshin's anniversary selectors with no word in // common, and Game8's own blurb names the Fandom event. That is the page // saying they are one, which is what a person read to establish it. const g8 = event({ id: "genshin:free-limited-5-star-anniversary-selector:2026-09-23", title: "Free Limited 5-Star Anniversary Selector", summary: "Choose a 5-Star event-exclusive character for free in the new, annual, limited 5-star selector, Tabletop Troupe: A Gathering on Adventure's Eve!", startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-03T00:00:00.000Z", sourceId: "genshin-game8-events", confidence: 0.85, }); const fandom = event({ id: "genshin:tabletop-troupe-a-gathering-on-adventures-eve:2026-09-23", title: "Tabletop Troupe: A Gathering on Adventure's Eve", startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-03T00:00:00.000Z", sourceId: "genshin-fandom-events", confidence: 0.85, }); const fused = mergeEvents([[g8], [fandom]]); expect(fused.events.map((e) => e.id)).toEqual([g8.id]); expect(fused.events[0]?.confidence).toBeCloseTo(0.95, 5); // Same blurb, but the named event runs a different window: a summary may // mention another event ("complete X to unlock this"), so both boundaries // must agree before a mention counts. const laterEnd = { ...fandom, endsAt: "2026-10-20T00:00:00.000Z" }; expect(mergeEvents([[g8], [laterEnd]]).events).toHaveLength(2); // A summary that names nothing of the other's title is not evidence. const standardFandom = { ...fandom, id: "genshin:across-the-frozen-wilds-honing-ones-edge:2026-09-23", title: "Across the Frozen Wilds, Honing One's Edge", }; expect(mergeEvents([[g8], [standardFandom]]).events).toHaveLength(2); }); test("a blurb naming a parent event does not fuse the sub-event into it", () => { // Both sources list the festival and its shop over one window, and Game8's // shop blurb names the festival. The shop already has its own twin on // Fandom, so the mention is about another event, not about itself. const window = { startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-03T00:00:00.000Z" }; const g8Shop = event({ ...window, id: "genshin:festival-shop:2026-09-23", title: "Festival Shop", summary: "Spend tokens from Starlit Gala Festival in the Festival Shop.", sourceId: "genshin-game8-events", }); const fandomFestival = event({ ...window, id: "genshin:starlit-gala-festival:2026-09-23", title: "Starlit Gala Festival", sourceId: "genshin-fandom-events", }); const fandomShop = event({ ...window, id: "genshin:festival-shop:2026-09-23", title: "Festival Shop", sourceId: "genshin-fandom-events", }); const { events } = mergeEvents([[g8Shop], [fandomFestival, fandomShop]]); expect(events.map((e) => e.id).sort()).toEqual( [fandomFestival.id, g8Shop.id].sort(), ); }); test("a one-word title in a summary is not a mention", () => { const g8 = event({ id: "genshin:free-selector:2026-09-23", title: "Free Selector", summary: "Pick a character before Moontrace ends.", sourceId: "genshin-game8-events", }); const fandom = event({ id: "genshin:moontrace:2026-08-12", title: "Moontrace", sourceId: "genshin-fandom-events", }); expect(mergeEvents([[g8], [fandom]]).events).toHaveLength(2); }); 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("a recorded pair matches in either order, and only itself", () => { const table = [{ ids: ["g:a:2026-01-01", "g:b:2026-01-01"] as const, evidence: "test" }]; expect(recordedSame("g:b:2026-01-01", "g:a:2026-01-01", table)).toBe(true); expect(recordedSame("g:a:2026-01-01", "g:c:2026-01-01", table)).toBe(false); }); 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(); }); test("collapses a leading-label duplicate and keeps the first copy's id", () => { const g8 = event({ id: "genshin:battle-pass-frozen-wilds:2026-09-23", title: "Battle Pass - Frozen Wilds", startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-02T00:00:00.000Z", sourceId: "genshin-game8-events", confidence: 0.85, }); const fandom = event({ id: "genshin:frozen-wilds:2026-09-23", title: "Frozen Wilds", startsAt: "2026-09-23T00:00:00.000Z", endsAt: "2026-11-02T00:00:00.000Z", sourceId: "genshin-fandom-events", confidence: 0.85, }); const { events, conflicts } = mergeEvents([[g8], [fandom]]); expect(events).toHaveLength(1); expect(events[0]?.id).toBe(g8.id); expect(conflicts).toHaveLength(0); }); }); describe("near matches within one source", () => { test("two rows from one page stay two events", () => { // The page has already told us these are two things, and the parser has // already dropped repeats of the same id — so fusing them here overrules a // distinction the publisher made on purpose. Game8's Umamusume banner list // is the real case: two concurrent banners whose titles differ by one // parenthetical and which start on the same day. Fusing them dropped a live // banner off the calendar with nothing reporting it. const character = event({ id: "uma:3-star-guaranteed-1-5-anniversary-scout-character:2026-07-22", game: "uma", title: "3 Star Guaranteed 1.5 Anniversary Scout (Character)", sourceId: "uma-game8-events", }); const support = event({ id: "uma:3-star-guaranteed-1-5-anniversary-scout-support:2026-07-22", game: "uma", title: "3 Star Guaranteed 1.5 Anniversary Scout (Support)", sourceId: "uma-game8-events", }); const merged = mergeEvents([[character, support]]); expect(merged.events).toHaveLength(2); expect(merged.conflicts).toHaveLength(0); }); test("the same id twice from one source is still one event", () => { // The exception that keeps the rule safe: an identical id is the same row // seen twice, whatever it is called. const once = event({ sourceId: "source-a" }); const twice = event({ sourceId: "source-a", title: "Test Event (again)" }); expect(mergeEvents([[once, twice]]).events).toHaveLength(1); }); test("near matches across two sources still fuse", () => { // The behaviour this rule narrows, not removes: reconciling two sources // that describe one event under different titles is the whole job. const a = event({ title: "Bedazzling Dawnstar", sourceId: "source-a" }); const b = event({ id: "genshin:bedazzling-dawnstar-sign-in:2026-08-12", title: "Bedazzling Dawnstar Sign-In", sourceId: "source-b", }); expect(mergeEvents([[a], [b]]).events).toHaveLength(1); }); });