diff --git a/lib/demo-data.ts b/lib/demo-data.ts index f71585d..daca5ab 100644 --- a/lib/demo-data.ts +++ b/lib/demo-data.ts @@ -60,7 +60,41 @@ export const ENGLAND_ARGENTINA_FIXTURE: Fixture = { gameState: 1, }; +export const FRANCE_ENGLAND_FIXTURE: Fixture = { + fixtureId: 18257865, + homeTeam: "France", + awayTeam: "England", + startTime: "2026-07-18T21:00:00.000Z", + competition: "FIFA World Cup 2026", + competitionSource: "published-report", + competitionSourceUrl: "https://www.skysports.com/football/france-vs-england/report/549868", + stage: "Third place · Match 103 · Miami Stadium", + gameState: 1, +}; + +export const SPAIN_ARGENTINA_FIXTURE: Fixture = { + fixtureId: 18257739, + homeTeam: "Spain", + awayTeam: "Argentina", + startTime: "2026-07-19T19:00:00.000Z", + competition: "FIFA World Cup 2026", + competitionSource: "published-report", + competitionSourceUrl: "https://www.skysports.com/football/spain-vs-argentina/report/549869", + stage: "Final · Match 104 · New York New Jersey Stadium", + gameState: 1, +}; + export const DEMO_DATA_SOURCES: Record = { + [SPAIN_ARGENTINA_FIXTURE.fixtureId]: { + provider: "Sky Sports full-time report + AP live match report", + url: "https://www.skysports.com/football/spain-vs-argentina/report/549869", + checkedAt: "2026-07-19T23:25:00.000Z", + }, + [FRANCE_ENGLAND_FIXTURE.fixtureId]: { + provider: "Sky Sports full-time match report", + url: "https://www.skysports.com/football/france-vs-england/report/549868", + checkedAt: "2026-07-19T07:46:00.000Z", + }, [ENGLAND_ARGENTINA_FIXTURE.fixtureId]: { provider: "TxLINE final snapshot + AP match report", url: "https://apnews.com/article/world-cup-england-argentina-score-2ae6a218ae88248db6565ffd13f60d38", @@ -154,9 +188,45 @@ export const ENGLAND_ARGENTINA_MOMENTS = createMoments(ENGLAND_ARGENTINA_FIXTURE { id: "eng-arg-final", seq: 962, minute: 92, minuteLabel: "90+2", type: "final", team: "neutral", title: "Argentina reach the World Cup final", description: "Full-time: England 1–2 Argentina. Argentina advance to face Spain; England move to the third-place match against France.", points: 20, badge: 7, score: [1, 2], txlineAction: "game_finalised" }, ]); -export const DEMO_FIXTURES = [ENGLAND_ARGENTINA_FIXTURE, FRANCE_SPAIN_FIXTURE, DEMO_FIXTURE, PORTUGAL_SPAIN_FIXTURE, BRAZIL_NORWAY_FIXTURE]; +export const FRANCE_ENGLAND_MOMENTS = createMoments(FRANCE_ENGLAND_FIXTURE, [ + { id: "fra-eng-kickoff", seq: 1, minute: 0, type: "kickoff", team: "neutral", title: "Bronze-final underway", description: "France and England begin the World Cup third-place match in Miami.", points: 5, badge: 0, score: [0, 0], txlineAction: "game_started" }, + { id: "fra-eng-rice-3", seq: 18, minute: 3, type: "goal", team: "away", title: "Rice gives England a rapid lead", description: "Declan Rice opens the scoring for England in the third minute.", participant: "Declan Rice", points: 12, badge: 2, score: [0, 1], txlineAction: "goal" }, + { id: "fra-eng-konsa-18", seq: 74, minute: 18, type: "goal", team: "away", title: "Konsa doubles the advantage", description: "Ezri Konsa heads in Declan Rice's corner to make it 2–0.", participant: "Ezri Konsa", assist: "Declan Rice", points: 12, badge: 2, score: [0, 2], txlineAction: "goal" }, + { id: "fra-eng-saka-37", seq: 148, minute: 37, type: "goal", team: "away", title: "Saka adds England's third", description: "Bukayo Saka scores from Marcus Rashford's assist in the 37th minute.", participant: "Bukayo Saka", assist: "Marcus Rashford", points: 12, badge: 2, score: [0, 3], txlineAction: "goal" }, + { id: "fra-eng-saka-46", seq: 183, minute: 46, minuteLabel: "45+1", type: "goal", team: "away", title: "Saka makes it four before the break", description: "Bukayo Saka scores his second from Eberechi Eze's assist in first-half stoppage time.", participant: "Bukayo Saka", assist: "Eberechi Eze", points: 15, badge: 6, score: [0, 4], txlineAction: "goal" }, + { id: "fra-eng-halftime", seq: 184, minute: 46, minuteLabel: "45+1", type: "halftime", team: "neutral", title: "England lead by four at half-time", description: "England reach the interval 4–0 ahead in the bronze-final.", points: 8, badge: 4, score: [0, 4], txlineAction: "halftime_finalised" }, + { id: "fra-eng-mbappe-48", seq: 192, minute: 48, type: "goal", team: "home", title: "Mbappé begins the response", description: "Kylian Mbappé scores from Michael Olise's assist three minutes after the restart.", participant: "Kylian Mbappé", assist: "Michael Olise", points: 12, badge: 2, score: [1, 4], txlineAction: "goal" }, + { id: "fra-eng-barcola-54", seq: 216, minute: 54, type: "goal", team: "home", title: "Barcola cuts the deficit", description: "Bradley Barcola scores from Kylian Mbappé's assist as France continue their comeback.", participant: "Bradley Barcola", assist: "Kylian Mbappé", points: 12, badge: 2, score: [2, 4], txlineAction: "goal" }, + { id: "fra-eng-mbappe-66", seq: 264, minute: 66, type: "goal", team: "home", title: "Mbappé brings France within one", description: "Kylian Mbappé scores his second from another Michael Olise assist.", participant: "Kylian Mbappé", assist: "Michael Olise", points: 15, badge: 6, score: [3, 4], txlineAction: "goal" }, + { id: "fra-eng-saka-87", seq: 348, minute: 87, type: "goal", team: "away", title: "Saka completes his hat-trick", description: "Bukayo Saka converts the penalty won by Djed Spence to restore England's two-goal lead.", participant: "Bukayo Saka", points: 15, badge: 6, score: [3, 5], txlineAction: "penalty_goal" }, + { id: "fra-eng-dembele-96", seq: 382, minute: 96, minuteLabel: "90+6", type: "goal", team: "home", title: "Dembélé keeps France alive", description: "Ousmane Dembélé scores from Dayot Upamecano's assist deep into stoppage time.", participant: "Ousmane Dembélé", assist: "Dayot Upamecano", points: 12, badge: 2, score: [4, 5], txlineAction: "goal" }, + { id: "fra-eng-bellingham-98", seq: 390, minute: 98, minuteLabel: "90+8", type: "goal", team: "away", title: "Bellingham settles the thriller", description: "Jude Bellingham scores the tenth and final goal of the bronze-final.", participant: "Jude Bellingham", points: 15, badge: 6, score: [4, 6], txlineAction: "goal" }, + { id: "fra-eng-final", seq: 391, minute: 98, minuteLabel: "90+8", type: "final", team: "neutral", title: "England win World Cup bronze", description: "Full-time: France 4–6 England. England finish third after a ten-goal match in Miami.", points: 20, badge: 7, score: [4, 6], txlineAction: "game_finalised" }, +]); + +export const SPAIN_ARGENTINA_MOMENTS = createMoments(SPAIN_ARGENTINA_FIXTURE, [ + { id: "esp-arg-kickoff", seq: 1, minute: 0, type: "kickoff", team: "neutral", title: "World Cup final underway", description: "Spain and Argentina begin the 2026 World Cup final in New York New Jersey.", points: 5, badge: 0, score: [0, 0], txlineAction: "game_started" }, + { id: "esp-arg-yamal-shot-5", seq: 20, minute: 5, type: "shot", team: "home", title: "Yamal tests Martínez", description: "Lamine Yamal records the first shot on target of the final.", participant: "Lamine Yamal", points: 4, badge: 1, score: [0, 0], txlineAction: "shot_on_target" }, + { id: "esp-arg-oyarzabal-shot-39", seq: 156, minute: 39, type: "shot", team: "home", title: "Oyarzabal shoots at goal", description: "Mikel Oyarzabal's attempt goes straight at Emiliano Martínez.", participant: "Mikel Oyarzabal", points: 4, badge: 1, score: [0, 0], txlineAction: "shot_on_target" }, + { id: "esp-arg-halftime", seq: 180, minute: 45, type: "halftime", team: "neutral", title: "The final remains goalless", description: "Spain and Argentina reach half-time at 0–0.", points: 8, badge: 4, score: [0, 0], txlineAction: "halftime_finalised" }, + { id: "esp-arg-paredes-yellow-52", seq: 208, minute: 52, type: "card", team: "away", title: "Paredes is booked", description: "Leandro Paredes receives a yellow card after a confrontation with Dani Olmo.", participant: "Leandro Paredes", cardColor: "yellow", points: 5, badge: 3, score: [0, 0], txlineAction: "yellow_card" }, + { id: "esp-arg-torres-on-62", seq: 248, minute: 62, type: "substitution", team: "home", title: "Spain introduce Ferran Torres", description: "Ferran Torres replaces Mikel Oyarzabal as Spain search for the breakthrough.", participant: "Ferran Torres", points: 3, badge: 1, score: [0, 0], txlineAction: "substitution" }, + { id: "esp-arg-double-chance-77", seq: 308, minute: 77, type: "shot", team: "home", title: "Martínez keeps Spain out", description: "Pedro Porro and Pau Cubarsí both draw saves from Emiliano Martínez.", participant: "Pedro Porro · Pau Cubarsí", points: 4, badge: 1, score: [0, 0], txlineAction: "shot_on_target" }, + { id: "esp-arg-fernandez-red-93", seq: 372, minute: 93, minuteLabel: "90+3", type: "card", team: "away", title: "Argentina are reduced to ten", description: "Enzo Fernández receives a second yellow card after a reckless challenge on Pau Cubarsí.", participant: "Enzo Fernández", cardColor: "red", points: 8, badge: 5, score: [0, 0], txlineAction: "second_yellow_card" }, + { id: "esp-arg-yamal-save-99", seq: 396, minute: 99, minuteLabel: "90+9", type: "shot", team: "home", title: "Martínez denies Yamal", description: "Emiliano Martínez makes a strong save from Lamine Yamal's whipped effort.", participant: "Lamine Yamal", points: 4, badge: 1, score: [0, 0], txlineAction: "shot_saved" }, + { id: "esp-arg-williams-no-goal-96", seq: 408, minute: 100, minuteLabel: "96", type: "var", team: "home", title: "Williams goal does not stand", description: "Nico Williams puts the ball in the net, but a foul by Mikel Merino on Nicolás Otamendi is called in the build-up.", participant: "Nico Williams", varOutcome: "Goal disallowed · foul in build-up", points: 8, badge: 5, score: [0, 0], txlineAction: "var_goal_disallowed" }, + { id: "esp-arg-torres-goal-106", seq: 460, minute: 106, type: "goal", team: "home", title: "Torres fires Spain ahead", description: "Ferran Torres finishes Nico Williams' headed pass to break the deadlock in extra time.", participant: "Ferran Torres", assist: "Nico Williams", points: 15, badge: 6, score: [1, 0], txlineAction: "goal" }, + { id: "esp-arg-torres-offside-114", seq: 492, minute: 114, type: "var", team: "home", title: "Second Torres finish ruled out", description: "Ferran Torres finds the net again but is flagged offside.", participant: "Ferran Torres", varOutcome: "Goal disallowed · offside", points: 6, badge: 1, score: [1, 0], txlineAction: "goal_disallowed_offside" }, + { id: "esp-arg-simeone-over-122", seq: 524, minute: 122, minuteLabel: "120+2", type: "shot", team: "away", title: "Simeone misses Argentina's late chance", description: "Giuliano Simeone sends Argentina's late opportunity over the goal.", participant: "Giuliano Simeone", points: 4, badge: 1, score: [1, 0], txlineAction: "shot_off_target" }, + { id: "esp-arg-paredes-red-126", seq: 540, minute: 126, minuteLabel: "120+6", type: "card", team: "away", title: "Paredes is dismissed after full-time", description: "Leandro Paredes receives a red card in the confrontation after the final whistle.", participant: "Leandro Paredes", cardColor: "red", points: 0, badge: 0, score: [1, 0], txlineAction: "red_card" }, + { id: "esp-arg-final", seq: 541, minute: 126, minuteLabel: "120+6", type: "final", team: "neutral", title: "Spain are world champions", description: "After extra time: Spain 1–0 Argentina. Spain win their second men's World Cup title.", points: 20, badge: 7, score: [1, 0], txlineAction: "game_finalised" }, +]); + +export const DEMO_FIXTURES = [SPAIN_ARGENTINA_FIXTURE, FRANCE_ENGLAND_FIXTURE, ENGLAND_ARGENTINA_FIXTURE, FRANCE_SPAIN_FIXTURE, DEMO_FIXTURE, PORTUGAL_SPAIN_FIXTURE, BRAZIL_NORWAY_FIXTURE]; export const DEMO_MOMENTS_BY_FIXTURE: Record = { + [SPAIN_ARGENTINA_FIXTURE.fixtureId]: SPAIN_ARGENTINA_MOMENTS, + [FRANCE_ENGLAND_FIXTURE.fixtureId]: FRANCE_ENGLAND_MOMENTS, [ENGLAND_ARGENTINA_FIXTURE.fixtureId]: ENGLAND_ARGENTINA_MOMENTS, [FRANCE_SPAIN_FIXTURE.fixtureId]: FRANCE_SPAIN_MOMENTS, [DEMO_FIXTURE.fixtureId]: DEMO_MOMENTS, @@ -165,6 +235,8 @@ export const DEMO_MOMENTS_BY_FIXTURE: Record = { }; export const DEMO_DEFAULT_PHASE: Record = { + [SPAIN_ARGENTINA_FIXTURE.fixtureId]: "FT", + [FRANCE_ENGLAND_FIXTURE.fixtureId]: "FT", [ENGLAND_ARGENTINA_FIXTURE.fixtureId]: "FT", [FRANCE_SPAIN_FIXTURE.fixtureId]: "FT", [BRAZIL_NORWAY_FIXTURE.fixtureId]: "FT", diff --git a/lib/schedule.ts b/lib/schedule.ts index d438d04..033c102 100644 --- a/lib/schedule.ts +++ b/lib/schedule.ts @@ -1,9 +1,10 @@ import type { Fixture, ScheduleEntry } from "@/types/pulse"; const AP_ENGLAND_URL = "https://apnews.com/article/world-cup-england-argentina-score-2ae6a218ae88248db6565ffd13f60d38"; -const AP_FINAL_URL = "https://apnews.com/article/argentina-messi-spain-yamal-world-cup-final-55077ce5c4728c4207a39cc4aa8a41a1"; const FRANCE_SPAIN_REPORT_URL = "https://www.skysports.com/football/france-vs-spain/549866"; -const VERIFIED_AT = "2026-07-16T00:25:00.000Z"; +const THIRD_PLACE_REPORT_URL = "https://www.skysports.com/football/france-vs-england/report/549868"; +const FINAL_REPORT_URL = "https://www.skysports.com/football/spain-vs-argentina/report/549869"; +const VERIFIED_AT = "2026-07-19T23:25:00.000Z"; export const ELIMINATED_TEAMS_AS_OF_VERIFICATION = new Set([ "Brazil", "Norway", "Switzerland", "Morocco", "Portugal", "Belgium", "France", "England", @@ -42,22 +43,24 @@ const VERIFIED_TOURNAMENT_FIXTURES: Array<{ result: { phase: "FT", score: [1, 2], winnerTeam: "Argentina", loserTeam: "England", replayFixtureId: 18241006 }, }, { - fixture: { fixtureId: 103, homeTeam: "France", awayTeam: "England", startTime: "2026-07-18T21:00:00.000Z", competition: "FIFA World Cup 2026", competitionSource: "verified-schedule", competitionSourceUrl: AP_FINAL_URL, stage: "Third place · Match 103 · Miami Stadium", gameState: 0 }, + fixture: { fixtureId: 103, homeTeam: "France", awayTeam: "England", startTime: "2026-07-18T21:00:00.000Z", competition: "FIFA World Cup 2026", competitionSource: "published-report", competitionSourceUrl: THIRD_PLACE_REPORT_URL, stage: "Third place · Match 103 · Miami Stadium", gameState: 1 }, coverage: "externally-confirmed", - provider: "FIFA schedule + both published semi-final results", - sourceUrl: AP_FINAL_URL, + provider: "Sky Sports full-time match report", + sourceUrl: THIRD_PLACE_REPORT_URL, txlineFixtureId: 18257865, + result: { phase: "FT", score: [4, 6], winnerTeam: "England", loserTeam: "France", replayFixtureId: 18257865 }, participantPaths: { home: { kind: "loser", fixtureId: 101, label: "France" }, away: { kind: "loser", fixtureId: 102, label: "England" }, }, }, { - fixture: { fixtureId: 104, homeTeam: "Spain", awayTeam: "Argentina", startTime: "2026-07-19T19:00:00.000Z", competition: "FIFA World Cup 2026", competitionSource: "verified-schedule", competitionSourceUrl: AP_FINAL_URL, stage: "Final · Match 104 · New York New Jersey Stadium", gameState: 0 }, + fixture: { fixtureId: 104, homeTeam: "Spain", awayTeam: "Argentina", startTime: "2026-07-19T19:00:00.000Z", competition: "FIFA World Cup 2026", competitionSource: "published-report", competitionSourceUrl: FINAL_REPORT_URL, stage: "Final · Match 104 · New York New Jersey Stadium", gameState: 1 }, coverage: "externally-confirmed", - provider: "FIFA schedule + both published semi-final results", - sourceUrl: AP_FINAL_URL, + provider: "Sky Sports full-time report + AP live match report", + sourceUrl: FINAL_REPORT_URL, txlineFixtureId: 18257739, + result: { phase: "FT", score: [1, 0], winnerTeam: "Spain", loserTeam: "Argentina", replayFixtureId: 18257739 }, participantPaths: { home: { kind: "winner", fixtureId: 101, label: "Spain" }, away: { kind: "winner", fixtureId: 102, label: "Argentina" }, @@ -166,8 +169,11 @@ export function scheduleIntegrityIssues(entries: ScheduleEntry[]): string[] { } export function formatKickoffCountdown(startTime: string, nowMs: number): string { - const remaining = Math.max(0, Date.parse(startTime) - nowMs); - if (!remaining) return "Kick-off now"; + const start = Date.parse(startTime); + if (!Number.isFinite(start)) return "Schedule unavailable"; + const remaining = start - nowMs; + if (remaining <= -60_000) return "Match started"; + if (remaining <= 0) return "Kick-off now"; const totalMinutes = Math.floor(remaining / 60_000); const days = Math.floor(totalMinutes / 1_440); const hours = Math.floor((totalMinutes % 1_440) / 60); diff --git a/tests/fan-preferences.test.ts b/tests/fan-preferences.test.ts index 1b067a2..14fe59e 100644 --- a/tests/fan-preferences.test.ts +++ b/tests/fan-preferences.test.ts @@ -35,8 +35,8 @@ describe("fan preferences and My Matches", () => { it("builds My Matches from either followed participant", () => { const matches = getDemoOverviews(); - expect(filterMatches(matches, "mine", ["Spain"]).map((match) => match.fixture.fixtureId)).toEqual([101, 18198205]); - expect(filterMatches(matches, "finished", [])).toHaveLength(5); + expect(filterMatches(matches, "mine", ["Spain"]).map((match) => match.fixture.fixtureId)).toEqual([18257739, 101, 18198205]); + expect(filterMatches(matches, "finished", [])).toHaveLength(7); expect(filterMatches(matches, "live", [])).toHaveLength(0); expect(fixtureHasFollowedTeam("France", "Spain", ["Spain"])).toBe(true); expect(fixtureHasFollowedTeam("France", "Morocco", ["Spain"])).toBe(false); diff --git a/tests/multi-match-replay.test.ts b/tests/multi-match-replay.test.ts index 446034c..b57138a 100644 --- a/tests/multi-match-replay.test.ts +++ b/tests/multi-match-replay.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; -import { DEMO_FIXTURES, DEMO_MOMENTS_BY_FIXTURE, ENGLAND_ARGENTINA_FIXTURE, FRANCE_SPAIN_MOMENTS, getDemoOverviews } from "@/lib/demo-data"; +import { + DEMO_FIXTURES, + DEMO_MOMENTS_BY_FIXTURE, + ENGLAND_ARGENTINA_FIXTURE, + FRANCE_ENGLAND_FIXTURE, + FRANCE_SPAIN_MOMENTS, + SPAIN_ARGENTINA_FIXTURE, + getDemoOverviews, +} from "@/lib/demo-data"; import { buildDemoPulse } from "@/lib/pulse-service"; import { pulseAtMoment, selectCatchUpSource, summarizeCatchUp } from "@/lib/pulse-replay"; describe("multi-match demo and catch-up", () => { it("provides a distinct replay timeline for every listed fixture", () => { - expect(DEMO_FIXTURES).toHaveLength(5); + expect(DEMO_FIXTURES).toHaveLength(7); for (const fixture of DEMO_FIXTURES) { expect(fixture.competition).toBe("FIFA World Cup 2026"); const pulse = buildDemoPulse(fixture.fixtureId); @@ -27,6 +35,8 @@ describe("multi-match demo and catch-up", () => { 18198205: [0, 1], 18209181: [2, 0], 18241006: [1, 2], + 18257865: [4, 6], + 18257739: [1, 0], }); }); @@ -67,6 +77,45 @@ describe("multi-match demo and catch-up", () => { expect(full.moments.at(-1)).toMatchObject({ type: "final", score: [1, 2] }); }); + it("replays all ten sourced goals from the World Cup bronze-final", () => { + const full = buildDemoPulse(FRANCE_ENGLAND_FIXTURE.fixtureId); + expect(full).toMatchObject({ phase: "FT", score: [4, 6] }); + expect(full.moments.filter((moment) => moment.type === "goal").map((moment) => [ + moment.minuteLabel ?? moment.minute, + moment.participant, + moment.score, + ])).toEqual([ + [3, "Declan Rice", [0, 1]], + [18, "Ezri Konsa", [0, 2]], + [37, "Bukayo Saka", [0, 3]], + ["45+1", "Bukayo Saka", [0, 4]], + [48, "Kylian Mbappé", [1, 4]], + [54, "Bradley Barcola", [2, 4]], + [66, "Kylian Mbappé", [3, 4]], + [87, "Bukayo Saka", [3, 5]], + ["90+6", "Ousmane Dembélé", [4, 5]], + ["90+8", "Jude Bellingham", [4, 6]], + ]); + expect(summarizeCatchUp(full.moments)).toMatchObject({ goals: 10, cards: 0, reviews: 0 }); + expect(full.moments.at(-1)).toMatchObject({ type: "final", score: [4, 6] }); + }); + + it("replays Spain's extra-time winner, dismissals and disallowed goals", () => { + const full = buildDemoPulse(SPAIN_ARGENTINA_FIXTURE.fixtureId); + expect(full).toMatchObject({ phase: "FT", score: [1, 0] }); + expect(full.provenance).toMatchObject({ provider: "Sky Sports full-time report + AP live match report" }); + expect(full.moments.find((moment) => moment.id === "esp-arg-fernandez-red-93")) + .toMatchObject({ minuteLabel: "90+3", participant: "Enzo Fernández", cardColor: "red", score: [0, 0] }); + expect(full.moments.find((moment) => moment.id === "esp-arg-torres-goal-106")) + .toMatchObject({ minute: 106, participant: "Ferran Torres", assist: "Nico Williams", score: [1, 0] }); + expect(full.moments.filter((moment) => moment.type === "var").map((moment) => moment.varOutcome)).toEqual([ + "Goal disallowed · foul in build-up", + "Goal disallowed · offside", + ]); + expect(summarizeCatchUp(full.moments)).toMatchObject({ goals: 1, cards: 3, reviews: 2 }); + expect(full.moments.at(-1)).toMatchObject({ type: "final", score: [1, 0] }); + }); + it("reconstructs score, minute and momentum at an exact catch-up position", () => { const full = buildDemoPulse(18209181); const atOpeningGoal = pulseAtMoment(full, 4); diff --git a/tests/schedule-reconciliation.test.ts b/tests/schedule-reconciliation.test.ts index b6793fd..3ff3ae8 100644 --- a/tests/schedule-reconciliation.test.ts +++ b/tests/schedule-reconciliation.test.ts @@ -34,28 +34,33 @@ function pulse(fixture: Fixture, score: [number, number], shootoutScore?: [numbe } describe("automatic tournament reconciliation", () => { - it("settles remaining fixtures, including a penalty winner, from TxLINE final snapshots", async () => { + it("settles both final weekend fixtures from final snapshots without inventing scores", async () => { + const unsettled = verifiedTournamentPath().map((entry) => [103, 104].includes(entry.fixture.fixtureId) + ? { ...entry, result: undefined } + : entry); const result = await reconcileTournamentPath( - verifiedTournamentPath(), + unsettled, async (fixture) => { if (fixture.fixtureId === 18241006) return pulse(fixture, [1, 2]); - if (fixture.fixtureId === 18257865) return pulse(fixture, [2, 1]); - if (fixture.fixtureId === 18257739) return pulse(fixture, [1, 1], [4, 5]); + if (fixture.fixtureId === 18257865) return pulse(fixture, [4, 6]); + if (fixture.fixtureId === 18257739) return pulse(fixture, [1, 0]); throw new Error("not covered"); }, new Date("2026-07-20T23:00:00.000Z"), ); expect(result.find((entry) => entry.fixture.fixtureId === 103)?.result).toMatchObject({ - score: [2, 1], winnerTeam: "France", loserTeam: "England", replayFixtureId: 18257865, + score: [4, 6], winnerTeam: "England", loserTeam: "France", replayFixtureId: 18257865, }); expect(result.find((entry) => entry.fixture.fixtureId === 104)?.result).toMatchObject({ - score: [1, 1], shootoutScore: [4, 5], winnerTeam: "Argentina", loserTeam: "Spain", replayFixtureId: 18257739, + score: [1, 0], winnerTeam: "Spain", loserTeam: "Argentina", replayFixtureId: 18257739, }); expect(scheduleIntegrityIssues(result)).toEqual([]); }); it("never invents a winner before TxLINE emits the final marker", async () => { - const entries = verifiedTournamentPath(); + const entries = verifiedTournamentPath().map((entry) => [103, 104].includes(entry.fixture.fixtureId) + ? { ...entry, result: undefined } + : entry); const result = await reconcileTournamentPath(entries, async (fixture) => ({ ...pulse(fixture, [1, 0]), phase: "LIVE" }), new Date("2026-07-20T23:00:00.000Z")); expect(result.find((entry) => entry.fixture.fixtureId === 103)?.result).toBeUndefined(); expect(result.find((entry) => entry.fixture.fixtureId === 104)?.result).toBeUndefined(); diff --git a/tests/schedule.test.ts b/tests/schedule.test.ts index b7b209f..6a614c3 100644 --- a/tests/schedule.test.ts +++ b/tests/schedule.test.ts @@ -13,24 +13,27 @@ describe("upcoming match schedule", () => { expect(scheduleIntegrityIssues(verifiedTournamentPath())).toEqual([]); }); - it("keeps both finished semi-finals and their replay IDs in the tournament graph", () => { + it("keeps every finished knockout result and replay ID in the tournament graph", () => { const path = verifiedTournamentPath(); expect(path.map((entry) => entry.fixture.fixtureId)).toEqual([101, 102, 103, 104]); expect(path[0].result).toEqual({ phase: "FT", score: [0, 2], winnerTeam: "Spain", loserTeam: "France", replayFixtureId: 101 }); expect(path[1].result).toEqual({ phase: "FT", score: [1, 2], winnerTeam: "Argentina", loserTeam: "England", replayFixtureId: 18241006 }); + expect(path[2].result).toEqual({ phase: "FT", score: [4, 6], winnerTeam: "England", loserTeam: "France", replayFixtureId: 18257865 }); + expect(path[3].result).toEqual({ phase: "FT", score: [1, 0], winnerTeam: "Spain", loserTeam: "Argentina", replayFixtureId: 18257739 }); expect(path[3].participantPaths?.home).toMatchObject({ kind: "winner", fixtureId: 101, label: "Spain" }); expect(path[3].participantPaths?.away).toMatchObject({ kind: "winner", fixtureId: 102, label: "Argentina" }); }); it("rejects an eliminated team inside a confirmed final fixture", () => { const confirmed = verifiedSchedule(new Date("2026-07-16T05:00:00.000Z")).find((entry) => entry.fixture.fixtureId === 104)!; - const corrupt = [{ ...confirmed, fixture: { ...confirmed.fixture, homeTeam: "Brazil" } }]; + const corrupt = [{ ...confirmed, result: undefined, fixture: { ...confirmed.fixture, homeTeam: "Brazil" } }]; expect(scheduleIntegrityIssues(corrupt)).toContain("eliminated team Brazil appears in confirmed future fixture 104"); }); it("formats countdowns without exposing negative time", () => { expect(formatKickoffCountdown("2026-07-14T19:00:00.000Z", Date.parse("2026-07-12T17:30:00.000Z"))).toBe("2d 1h 30m"); - expect(formatKickoffCountdown("2026-07-14T19:00:00.000Z", Date.parse("2026-07-14T20:00:00.000Z"))).toBe("Kick-off now"); + expect(formatKickoffCountdown("2026-07-14T19:00:00.000Z", Date.parse("2026-07-14T19:00:00.000Z"))).toBe("Kick-off now"); + expect(formatKickoffCountdown("2026-07-14T19:00:00.000Z", Date.parse("2026-07-14T20:00:00.000Z"))).toBe("Match started"); }); it("creates a UTC calendar event with a ten-minute alarm and stable fixture UID", () => { diff --git a/tests/tournament-journey.test.ts b/tests/tournament-journey.test.ts index 8950511..ad8d69e 100644 --- a/tests/tournament-journey.test.ts +++ b/tests/tournament-journey.test.ts @@ -11,13 +11,18 @@ describe("tournament journey", () => { expect(journey.final?.fixture).toMatchObject({ homeTeam: "Spain", awayTeam: "Argentina" }); expect(scheduleParticipantLabel(journey.final!, "away")).toBe("Argentina"); expect(journey.thirdPlace?.fixture).toMatchObject({ fixtureId: 103, homeTeam: "France", awayTeam: "England" }); + expect(journey.final?.result).toMatchObject({ winnerTeam: "Spain", score: [1, 0] }); + expect(journey.thirdPlace?.result).toMatchObject({ winnerTeam: "England", score: [4, 6] }); + expect(journey.nextForFan).toBeUndefined(); }); it("selects the next fixture containing a followed team", () => { - expect(buildTournamentJourney(schedule, ["Argentina"]).nextForFan?.fixture.fixtureId).toBe(104); + const beforeFinal = schedule.map((entry) => entry.fixture.fixtureId === 104 ? { ...entry, result: undefined } : entry); + expect(buildTournamentJourney(beforeFinal, ["Argentina"]).nextForFan?.fixture.fixtureId).toBe(104); }); it("falls back to the earliest unfinished fixture without favorites", () => { - expect(buildTournamentJourney(schedule, []).nextForFan?.fixture.fixtureId).toBe(103); + const beforeFinalWeekend = schedule.map((entry) => [103, 104].includes(entry.fixture.fixtureId) ? { ...entry, result: undefined } : entry); + expect(buildTournamentJourney(beforeFinalWeekend, []).nextForFan?.fixture.fixtureId).toBe(103); }); });