204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
|
|
/**
|
||
|
|
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
|
||
|
|
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
|
||
|
|
* teamStandings.totalPoints through a standings recalculation, so the action has to be
|
||
|
|
* sure one ran — markEliminatedAndAnnounce runs one for its Discord announcement in some
|
||
|
|
* cases but not others.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
|
import { generateBracketFromTemplate } from "~/models/playoff-match";
|
||
|
|
import {
|
||
|
|
findParticipantResultsBySportsSeasonId,
|
||
|
|
setParticipantResult,
|
||
|
|
} from "~/models/participant-result";
|
||
|
|
import {
|
||
|
|
applyBracketEntryFloors,
|
||
|
|
recalculateAffectedLeagues,
|
||
|
|
} from "~/models/scoring-calculator";
|
||
|
|
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||
|
|
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||
|
|
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||
|
|
|
||
|
|
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||
|
|
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
getScoringEventById: vi.fn(),
|
||
|
|
updateScoringEvent: vi.fn(),
|
||
|
|
isReadOnlySibling: vi.fn(() => false),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
generateBracketFromTemplate: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
findParticipantResultsBySportsSeasonId: vi.fn(),
|
||
|
|
setParticipantResult: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
applyBracketEntryFloors: vi.fn(),
|
||
|
|
recalculateAffectedLeagues: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||
|
|
...(await importOriginal<object>()),
|
||
|
|
findParticipantsBySportsSeasonId: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const params = { id: "season-1", eventId: "event-1" };
|
||
|
|
|
||
|
|
const EVENT = {
|
||
|
|
id: "event-1",
|
||
|
|
name: "AFL Finals",
|
||
|
|
sportsSeasonId: "season-1",
|
||
|
|
isQualifyingEvent: false,
|
||
|
|
bracketTemplateId: "afl_10",
|
||
|
|
};
|
||
|
|
|
||
|
|
/** afl_10 takes exactly 10 seeded participants. */
|
||
|
|
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
|
||
|
|
|
||
|
|
function generateRequest(): Request {
|
||
|
|
const body = new FormData();
|
||
|
|
body.set("intent", "generate-bracket");
|
||
|
|
body.set("templateId", "afl_10");
|
||
|
|
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
|
||
|
|
return new Request("http://localhost/generate", { method: "POST", body });
|
||
|
|
}
|
||
|
|
|
||
|
|
const run = (request: Request) =>
|
||
|
|
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||
|
|
error?: string;
|
||
|
|
success?: string;
|
||
|
|
}>)({ request, params });
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param extras participants in the season beyond the 10 seeded into the bracket —
|
||
|
|
* these are the ones generate-bracket marks eliminated.
|
||
|
|
* @param withExistingResults ids that already carry a result row, so
|
||
|
|
* markEliminatedAndAnnounce treats them as not newly eliminated.
|
||
|
|
*/
|
||
|
|
function setSeason(extras: string[], withExistingResults: string[] = []) {
|
||
|
|
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||
|
|
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
|
||
|
|
ReturnType<typeof findParticipantsBySportsSeasonId>
|
||
|
|
>
|
||
|
|
);
|
||
|
|
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||
|
|
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
|
||
|
|
ReturnType<typeof findParticipantResultsBySportsSeasonId>
|
||
|
|
>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("generate-bracket entry-floor standings recalculation", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||
|
|
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||
|
|
);
|
||
|
|
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
|
||
|
|
);
|
||
|
|
vi.mocked(updateScoringEvent).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
|
||
|
|
);
|
||
|
|
vi.mocked(setParticipantResult).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||
|
|
);
|
||
|
|
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||
|
|
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||
|
|
);
|
||
|
|
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
|
||
|
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("recalculates when every eliminated team already had a result row", async () => {
|
||
|
|
// The second run of a generation: the first wrote position 0 for the non-bracket
|
||
|
|
// participants, so nobody is *newly* eliminated and the announcement is skipped.
|
||
|
|
// The floors banked moments ago would never reach the standings.
|
||
|
|
setSeason(["extra-1"], ["extra-1"]);
|
||
|
|
|
||
|
|
const result = await run(generateRequest());
|
||
|
|
|
||
|
|
expect(result.success).toBeDefined();
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||
|
|
"season-1",
|
||
|
|
expect.anything(),
|
||
|
|
expect.objectContaining({ skipDiscord: true })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("recalculates for a qualifying event, which never announces eliminations", async () => {
|
||
|
|
vi.mocked(getScoringEventById).mockResolvedValue(
|
||
|
|
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
|
||
|
|
ReturnType<typeof getScoringEventById>
|
||
|
|
>
|
||
|
|
);
|
||
|
|
setSeason(["extra-1"]);
|
||
|
|
|
||
|
|
await run(generateRequest());
|
||
|
|
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||
|
|
"season-1",
|
||
|
|
expect.anything(),
|
||
|
|
expect.objectContaining({ skipDiscord: true })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("recalculates when the bracket field is the whole season", async () => {
|
||
|
|
setSeason([]);
|
||
|
|
|
||
|
|
await run(generateRequest());
|
||
|
|
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("recalculates when the elimination announcement threw", async () => {
|
||
|
|
// The announcement is best-effort and its failure is swallowed — but a failed recalc
|
||
|
|
// is exactly when the floors still need one.
|
||
|
|
setSeason(["extra-1"]);
|
||
|
|
vi.mocked(recalculateAffectedLeagues)
|
||
|
|
.mockRejectedValueOnce(new Error("discord down"))
|
||
|
|
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
|
||
|
|
|
||
|
|
const result = await run(generateRequest());
|
||
|
|
|
||
|
|
expect(result.success).toBeDefined();
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
|
||
|
|
"season-1",
|
||
|
|
expect.anything(),
|
||
|
|
expect.objectContaining({ skipDiscord: true })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not recalculate twice when the announcement already did", async () => {
|
||
|
|
setSeason(["extra-1"]);
|
||
|
|
|
||
|
|
await run(generateRequest());
|
||
|
|
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||
|
|
// The announcing call, not the floor fallback.
|
||
|
|
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||
|
|
"season-1",
|
||
|
|
expect.anything(),
|
||
|
|
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not recalculate at all when no floors were banked", async () => {
|
||
|
|
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
|
||
|
|
// so there is nothing for a recalculation to pick up.
|
||
|
|
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
|
||
|
|
setSeason([]);
|
||
|
|
|
||
|
|
await run(generateRequest());
|
||
|
|
|
||
|
|
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|