/** * Advancing an AFL Elimination Finals winner into the Semi-Finals. * * Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final * n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a * Qualifying Final loser cannot meet the side that just beat it. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { AFL_10 } from "~/lib/bracket-templates"; interface MatchRow { id: string; scoringEventId: string; round: string; matchNumber: number; participant1Id: string | null; participant2Id: string | null; isComplete: boolean; winnerId: string | null; loserId: string | null; } let rows: MatchRow[] = []; /** * The literal values drizzle put in a where clause (`eq(col, value)`), which is all this * mock needs to tell one lookup from another — there is no query engine behind it. */ function whereValues(node: unknown, depth = 0): string[] { if (!node || depth > 10) return []; if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1)); if (typeof node !== "object") return []; const obj = node as Record; const own = typeof obj.value === "string" ? [obj.value] : []; return [...own, ...whereValues(obj.queryChunks, depth + 1)]; } const db = { query: { playoffMatches: { findFirst: vi.fn(({ where }: { where: unknown }) => { const values = whereValues(where); return Promise.resolve(rows.find((r) => values.includes(r.id))); }), findMany: vi.fn(({ where }: { where: unknown }) => { const values = whereValues(where); return Promise.resolve( rows .filter((r) => values.includes(r.scoringEventId) && values.includes(r.round)) .toSorted((a, b) => a.matchNumber - b.matchNumber) ); }), }, }, update: vi.fn(() => ({ set: (data: Partial) => { const applyTo = (where: unknown) => { const values = whereValues(where); const target = rows.find((r) => values.includes(r.id)); if (target) Object.assign(target, data); return target; }; // Advancement writes through the query builder with and without .returning(). return { where: (where: unknown) => { const applied = Promise.resolve([applyTo(where)]); return Object.assign(applied, { returning: () => applied }); }, }; }, })), // No rollback: the tests assert the writes that were attempted, in order. transaction: vi.fn((fn: (tx: typeof db) => Promise) => fn(db)), }; vi.mock("~/database/context", () => ({ database: () => db })); const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match"); const EVENT = "event-1"; /** * The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle, * 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs, * 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the * Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs. */ const FREO = "fremantle"; const SYDNEY = "sydney"; const BRISBANE = "brisbane"; const HAWTHORN = "hawthorn"; const GEELONG = "geelong"; const ADELAIDE = "adelaide"; const BULLDOGS = "bulldogs"; const CARLTON = "carlton"; /** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */ function bracket(): MatchRow[] { const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null }; return [ { ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO }, { ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE }, { ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON }, { ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS }, // Filled by the Qualifying Final losers, as advancement already does. { ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null }, { ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null }, { ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null }, { ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null }, ]; } function row(id: string): MatchRow { const found = rows.find((r) => r.id === id); if (!found) throw new Error(`No such match ${id}`); return found; } /** Record a result the way setMatchWinner does, then advance it. */ async function win(id: string, winnerId: string) { const match = row(id); match.winnerId = winnerId; match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id; match.isComplete = true; await advanceWinnerTemplate(id, winnerId, AFL_10); } const pairing = () => ({ sf1: [row("sf1").participant1Id, row("sf1").participant2Id], sf2: [row("sf2").participant1Id, row("sf2").participant2Id], }); beforeEach(() => { rows = bracket(); vi.clearAllMocks(); }); describe("Elimination Finals → Semi-Finals advancement", () => { it("feeds Elimination Final 1 into Semi-Final 1", async () => { await win("ef1", GEELONG); expect(row("sf1").participant2Id).toBe(GEELONG); expect(row("sf2").participant2Id).toBeNull(); }); it("feeds Elimination Final 2 into Semi-Final 2", async () => { await win("ef2", ADELAIDE); expect(row("sf2").participant2Id).toBe(ADELAIDE); expect(row("sf1").participant2Id).toBeNull(); }); it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => { await win("ef1", GEELONG); await win("ef2", ADELAIDE); expect(pairing()).toEqual({ sf1: [FREO, GEELONG], sf2: [BRISBANE, ADELAIDE], }); }); it("draws the same Semi-Finals whichever order the results are entered", async () => { await win("ef2", ADELAIDE); await win("ef1", GEELONG); expect(pairing()).toEqual({ sf1: [FREO, GEELONG], sf2: [BRISBANE, ADELAIDE], }); }); it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => { await win("ef1", GEELONG); await win("ef2", ADELAIDE); // Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim. await win("sf1", FREO); expect(row("pf2").participant2Id).toBe(FREO); expect(row("pf1").participant2Id).toBeNull(); }); it("pulls the beaten team back out when an Elimination Final result is corrected", async () => { await win("ef1", GEELONG); expect(row("sf1").participant2Id).toBe(GEELONG); await win("ef1", CARLTON); expect(row("sf1").participant2Id).toBe(CARLTON); expect(row("sf2").participant2Id).toBeNull(); }); }); describe("reseedAflSemiFinals", () => { it("repairs a bracket left crossed by the old fixed crossover", async () => { // What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1. Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON }); Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS }); row("sf1").participant2Id = ADELAIDE; row("sf2").participant2Id = GEELONG; const reseed = await reseedAflSemiFinals(EVENT); expect(pairing()).toEqual({ sf1: [FREO, GEELONG], sf2: [BRISBANE, ADELAIDE], }); expect(reseed.vacated.toSorted()).toEqual([1, 2]); expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([ { matchNumber: 1, participantId: GEELONG }, { matchNumber: 2, participantId: ADELAIDE }, ]); }); it("writes nothing when the pairings are already right", async () => { Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON }); Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS }); row("sf1").participant2Id = GEELONG; row("sf2").participant2Id = ADELAIDE; const reseed = await reseedAflSemiFinals(EVENT); expect(reseed).toEqual({ vacated: [], filled: [] }); expect(db.update).not.toHaveBeenCalled(); }); it("leaves an undecided Elimination Final's slot TBD", async () => { Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON }); await reseedAflSemiFinals(EVENT); expect(row("sf1").participant2Id).toBe(GEELONG); expect(row("sf2").participant2Id).toBeNull(); }); it("refuses a slot held by someone who never played an Elimination Final", async () => { Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON }); row("sf1").participant2Id = SYDNEY; await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled"); }); it("refuses to move a qualifier out of a Semi-Final that has been played", async () => { Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON }); Object.assign(row("sf1"), { participant2Id: ADELAIDE, isComplete: true, winnerId: FREO, loserId: ADELAIDE, }); await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow( "Semi-Finals match 1 already has a recorded result" ); }); it("rejects an Elimination Final winner who is not one of its participants", async () => { Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON }); await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow( "Elimination Finals match 1 winner is not one of its participants" ); }); it("throws on an event with no Semi-Finals to re-seed", async () => { rows = rows.filter((r) => r.round !== "Semi-Finals"); await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow( "no AFL Elimination Finals / Semi-Finals matches to re-seed" ); }); });