The two Wildcard Round winners were crossed into the Elimination Finals by which game they came out of — the 7v10 winner always met 6th and the 8v9 winner always met 5th. The AFL pairs those games by ladder position, as the classic final eight pairs 5v8 and 6v7: the higher host draws the lower-ranked survivor. So when 10th wins through, 5th should meet 10th, not the 8v9 winner. Placement now resolves both games together. A 7v10 winner is placed as soon as it is decided (7th outranks either possible opponent, 10th is outranked by both), while an 8v9 winner is held until the other game is decided rather than being placed and later moved, so results can be entered in either order. The simulator paired the Elimination Finals the same fixed way, which biased every projection that ran off an undecided Wildcard Round; it now re-seeds too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
173 lines
6 KiB
TypeScript
173 lines
6 KiB
TypeScript
/**
|
|
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
|
*
|
|
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
|
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
|
* Wildcard match, and results can be recorded in either order.
|
|
*/
|
|
|
|
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>) => ({
|
|
where: (where: unknown) => ({
|
|
returning: () => {
|
|
const values = whereValues(where);
|
|
const row = rows.find((r) => values.includes(r.id));
|
|
if (row) Object.assign(row, data);
|
|
return Promise.resolve([row]);
|
|
},
|
|
}),
|
|
}),
|
|
})),
|
|
};
|
|
|
|
vi.mock("~/database/context", () => ({ database: () => db }));
|
|
|
|
const { advanceWinnerTemplate } = await import("../playoff-match");
|
|
|
|
const EVENT = "event-1";
|
|
|
|
/** Ladder seed n → participant id. */
|
|
const seed = (n: number) => `seed-${n}`;
|
|
|
|
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
|
function bracket(): MatchRow[] {
|
|
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
|
return [
|
|
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
|
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
|
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
|
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), 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 Wildcard result the way setMatchWinner does, then advance it. */
|
|
async function winWildcard(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);
|
|
}
|
|
|
|
describe("AFL Wildcard Round advancement", () => {
|
|
beforeEach(() => {
|
|
rows = bracket();
|
|
});
|
|
|
|
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
|
await winWildcard("wc1", seed(7));
|
|
await winWildcard("wc2", seed(8));
|
|
|
|
expect(row("ef1").participant2Id).toBe(seed(8));
|
|
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
});
|
|
|
|
it("re-seeds when the lower seed wins through", async () => {
|
|
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
|
// 5th with the better survivor.
|
|
await winWildcard("wc1", seed(10));
|
|
await winWildcard("wc2", seed(8));
|
|
|
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
});
|
|
|
|
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
|
await winWildcard("wc1", seed(10));
|
|
await winWildcard("wc2", seed(9));
|
|
|
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
expect(row("ef2").participant2Id).toBe(seed(9));
|
|
});
|
|
|
|
it("places the same pairings whichever result is entered first", async () => {
|
|
await winWildcard("wc2", seed(8));
|
|
await winWildcard("wc1", seed(10));
|
|
|
|
expect(row("ef1").participant2Id).toBe(seed(10));
|
|
expect(row("ef2").participant2Id).toBe(seed(8));
|
|
});
|
|
|
|
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
|
await winWildcard("wc1", seed(7));
|
|
|
|
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
expect(row("ef1").participant2Id).toBeNull();
|
|
});
|
|
|
|
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
|
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
|
await winWildcard("wc2", seed(8));
|
|
|
|
expect(row("ef1").participant2Id).toBeNull();
|
|
expect(row("ef2").participant2Id).toBeNull();
|
|
});
|
|
|
|
it("does not disturb a winner it already placed", async () => {
|
|
await winWildcard("wc1", seed(7));
|
|
await winWildcard("wc2", seed(9));
|
|
|
|
expect(row("ef2").participant2Id).toBe(seed(7));
|
|
expect(row("ef1").participant2Id).toBe(seed(9));
|
|
});
|
|
|
|
it("refuses to overwrite a slot already holding someone else", async () => {
|
|
row("ef1").participant2Id = "stranger";
|
|
|
|
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
|
expect(row("ef1").participant2Id).toBe("stranger");
|
|
});
|
|
});
|