The Elimination Final winners were crossed into the Semi-Finals — EF1's winner met the QF2 loser and EF2's the QF1 loser. The AFL feeds them straight through: SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2 winner. The crossover in this system lands a round later, at Semi-Final → Preliminary Final, so a Qualifying Final loser cannot meet the side that just beat it — that part was already right and is unchanged. In 2026 that drew Fremantle v Adelaide and Brisbane v Geelong, when Fremantle played Geelong and Brisbane played Adelaide. Placement now reconciles both Semi-Final slots on every Elimination Final result rather than writing the one it was called for, so correcting a recorded result moves the qualifier instead of leaving the beaten team alive in a semi. A slot held by anyone who never played an Elimination Final still raises "already filled", and a Semi-Final that has been played refuses the move rather than rewriting who contested it. The simulator paired the Semi-Finals the same crossed way, which biased every projection running off an undecided Elimination Final; it now feeds straight through too. Brackets already advanced under the crossover keep their wrong pairings, since no admin action re-runs advancement — a completed match cannot be re-submitted. Admin → the event's bracket gains a "Fix Semi-Final Pairings" button that runs the same reconciliation over a bracket as it stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSDeNWAXvK7nznJqjxn7Jo
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
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<MatchRow>) => {
|
|
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<unknown>) => 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"
|
|
);
|
|
});
|
|
});
|