174 lines
6 KiB
TypeScript
174 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");
|
||
|
|
});
|
||
|
|
});
|