2026-09-04 15:13:13 +00:00
|
|
|
/**
|
|
|
|
|
* 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(() => ({
|
2026-09-04 15:27:16 +00:00
|
|
|
set: (data: Partial<MatchRow>) => {
|
|
|
|
|
const applyTo = (where: unknown) => {
|
|
|
|
|
const values = whereValues(where);
|
2026-09-04 20:49:10 +00:00
|
|
|
const target = rows.find((r) => values.includes(r.id));
|
|
|
|
|
if (target) Object.assign(target, data);
|
|
|
|
|
return target;
|
2026-09-04 15:27:16 +00:00
|
|
|
};
|
|
|
|
|
// 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 });
|
2026-09-04 15:13:13 +00:00
|
|
|
},
|
2026-09-04 15:27:16 +00:00
|
|
|
};
|
|
|
|
|
},
|
2026-09-04 15:13:13 +00:00
|
|
|
})),
|
2026-09-04 15:27:16 +00:00
|
|
|
// No rollback: the tests assert the writes that were attempted, in order.
|
|
|
|
|
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
2026-09-04 15:13:13 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
vi.mock("~/database/context", () => ({ database: () => db }));
|
|
|
|
|
|
2026-09-04 21:30:57 +00:00
|
|
|
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
2026-09-04 15:13:13 +00:00
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
});
|
2026-09-04 15:27:16 +00:00
|
|
|
|
|
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 15:30:05 +00:00
|
|
|
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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 21:30:57 +00:00
|
|
|
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/);
|
|
|
|
|
});
|
|
|
|
|
|
2026-09-04 15:27:16 +00:00
|
|
|
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));
|
|
|
|
|
});
|
2026-09-04 15:13:13 +00:00
|
|
|
});
|