brackt/app/models/__tests__/afl-wildcard-advancement.test.ts
Claude 4e48a23f6b
Add a repair script for brackets advanced before the re-seed fix
The fix only changes how new results advance, so a bracket already advanced
under the fixed crossover keeps its wrong Elimination Finals pairings — and
the admin UI cannot re-trigger advancement, since a completed match renders
as "Complete" with no way to re-submit the winner.

Extract the reconcile step as reseedAflEliminationFinals, which resolves the
pairings from the results recorded so far (the winner being advanced is
passed in, since that row can predate the write), and add a script that runs
it over afl_10 events. It moves only Wildcard teams between the two
Elimination Final slots, leaves a correct bracket untouched, and reports
rather than rewrites an Elimination Final that has already been played.

  npx tsx scripts/fix-afl-wildcard-reseed.ts --dry

An event with no Wildcard/Elimination Finals matches now raises instead of
reporting nothing to do — a mistyped event id should not read as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDbHrCce1UhahbkwKkc7hK
2026-09-04 21:34:56 +00:00

271 lines
9.9 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>) => {
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, reseedAflEliminationFinals } = 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");
});
it("moves the winner when a recorded Wildcard result is corrected", async () => {
await winWildcard("wc1", seed(7));
expect(row("ef2").participant2Id).toBe(seed(7));
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
await winWildcard("wc1", seed(10));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBeNull();
});
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("swaps both winners when re-resolving an already-placed pair", async () => {
row("wc1").winnerId = seed(10);
row("wc1").loserId = seed(7);
row("wc1").isComplete = true;
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
await winWildcard("wc2", seed(8));
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
});
it("refuses to re-seed an Elimination Final that has already been played", async () => {
await winWildcard("wc1", seed(7));
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
expect(row("ef2").participant2Id).toBe(seed(7));
});
it("repairs an already-advanced bracket from the recorded results alone", async () => {
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
// bracket advanced under the old fixed crossover left behind.
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
row("ef2").participant2Id = seed(10);
row("ef1").participant2Id = seed(8);
const reseed = await reseedAflEliminationFinals(EVENT);
expect(row("ef1").participant2Id).toBe(seed(10));
expect(row("ef2").participant2Id).toBe(seed(8));
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
{ matchNumber: 1, participantId: seed(10) },
{ matchNumber: 2, participantId: seed(8) },
]);
});
it("reports no change when a repair run finds the pairings correct", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
const reseed = await reseedAflEliminationFinals(EVENT);
expect(reseed).toEqual({ vacated: [], filled: [] });
expect(db.transaction).not.toHaveBeenCalled();
});
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
});
it("leaves the bracket alone when the pairings are already right", async () => {
await winWildcard("wc1", seed(7));
await winWildcard("wc2", seed(8));
db.transaction.mockClear();
await winWildcard("wc2", seed(8));
expect(db.transaction).not.toHaveBeenCalled();
expect(row("ef1").participant2Id).toBe(seed(8));
expect(row("ef2").participant2Id).toBe(seed(7));
});
});