Files
gacha-event-tracker/test/merge.test.ts
T
Lucas WintherandClaude Opus 5.5 16c277b403 feat(merge): pair two copies when one's summary names the other
Game8 and Fandom name Genshin's anniversary selectors with no word in common, and both run the same window, so the calendar showed each selector twice. Game8's own blurb names the Fandom event ("…limited 5-star selector, Tabletop Troupe: A Gathering on Adventure's Eve!"), which is how a person paired them, so merge now reads it the same way.

The rule is narrow on purpose: the whole title of two or more words, both boundaries within 24h, and never for a copy that already has a twin by title in the other source, since then the blurb is naming a neighbour such as a festival's shop. Measured on every two-source page held, it fires on the two selector pairs and otherwise only where titles already matched. Dates alone never pair: patch day puts about twenty Genshin events on one window. aliases.ts is the recorded-pair fallback for copies no page links; it is empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:26 +02:00

397 lines
16 KiB
TypeScript

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> = {}): 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);
});
});