llws ev #144
6 changed files with 607 additions and 27 deletions
|
|
@ -108,6 +108,8 @@ export function NbaBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { NbaBracketLayout } from "../NbaBracketLayout";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import type { BracketMatch } from "../BracketTreeView";
|
||||
|
||||
/**
|
||||
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
||||
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
||||
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
||||
* where the feeder graph would name the game it is waiting on.
|
||||
*/
|
||||
|
||||
const TEMPLATE: BracketTemplate = {
|
||||
id: "test_conf_4",
|
||||
name: "Two-conference test bracket",
|
||||
totalTeams: 4,
|
||||
scoringStartsAtRound: "Final",
|
||||
rounds: [
|
||||
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
||||
],
|
||||
conferenceGroups: [
|
||||
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
||||
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
||||
],
|
||||
};
|
||||
|
||||
const ROUNDS = ["Semis", "Final"];
|
||||
|
||||
function match(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
overrides: Partial<BracketMatch> = {}
|
||||
): BracketMatch {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
...overrides,
|
||||
} as BracketMatch;
|
||||
}
|
||||
|
||||
/** Semis are played; the Final's two slots are still empty. */
|
||||
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
||||
[
|
||||
"Semis",
|
||||
[
|
||||
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
||||
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
||||
],
|
||||
],
|
||||
["Final", [match("Final", 1)]],
|
||||
]);
|
||||
|
||||
function renderLayout(withGraph: boolean) {
|
||||
const { container } = render(
|
||||
<NbaBracketLayout
|
||||
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
||||
rounds={ROUNDS}
|
||||
matchesByRound={MATCHES_BY_ROUND}
|
||||
ownershipMap={new Map()}
|
||||
userParticipantIds={new Set()}
|
||||
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
||||
scoringRoundIdx={1}
|
||||
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
||||
template={withGraph ? TEMPLATE : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
||||
// each assertion to the pane it is about.
|
||||
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
||||
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
||||
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
||||
return { mobile, desktop };
|
||||
}
|
||||
|
||||
describe("NbaBracketLayout", () => {
|
||||
it("names the feeding game in the mobile pager", () => {
|
||||
// Only slots filled by advancement get a label; a directly seeded slot with no
|
||||
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
||||
const { mobile } = renderLayout(true);
|
||||
|
||||
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
||||
const { mobile, desktop } = renderLayout(true);
|
||||
|
||||
const labels = (pane: HTMLElement) =>
|
||||
within(pane)
|
||||
.getAllByText(/Winner of/)
|
||||
.map((el) => el.textContent)
|
||||
.toSorted();
|
||||
|
||||
expect(labels(mobile)).toEqual(labels(desktop));
|
||||
});
|
||||
|
||||
it("falls back to TBD when the feeder graph is unavailable", () => {
|
||||
// Guards the assertions above: without feeders/template there is nothing to name a
|
||||
// slot with, which is exactly the state the mobile pane was stuck in.
|
||||
const { mobile } = renderLayout(false);
|
||||
|
||||
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
||||
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -104,6 +104,33 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
|||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the results of specific participants within one sports season.
|
||||
*
|
||||
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
||||
* sports season, not by event, so wiping the season takes every other event's placements
|
||||
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
||||
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
||||
*
|
||||
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
||||
*/
|
||||
export async function deleteParticipantResultsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set result for a participant in a sports season
|
||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
||||
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
||||
* and whether it takes the rest of the season's placements down with it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import {
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
setParticipantResult,
|
||||
} from "~/models/participant-result";
|
||||
import {
|
||||
applyBracketEntryFloors,
|
||||
processMatchResult,
|
||||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
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>()),
|
||||
findPlayoffMatchesByEventId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
deleteParticipantResultsForParticipants: vi.fn(),
|
||||
setParticipantResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
applyBracketEntryFloors: vi.fn(),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
processQualifyingBracketEvent: vi.fn(),
|
||||
finalizeQualifyingPoints: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findSportsSeasonById: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
isPrimary: false,
|
||||
tournamentId: null,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function reprocessRequest(): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reprocess-bracket");
|
||||
return new Request("http://localhost/reprocess", { method: "POST", body });
|
||||
}
|
||||
|
||||
/** A seeded, unplayed bracket slot. */
|
||||
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
||||
return {
|
||||
id: `m-${matchNumber}`,
|
||||
round: "Qualifying Finals",
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
isScoring: true,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
}
|
||||
|
||||
function setMatches(matches: ReturnType<typeof slot>[]) {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||
);
|
||||
}
|
||||
|
||||
describe("reprocess-bracket", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setEvent();
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
||||
);
|
||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||
);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
vi.mocked(processMatchResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
||||
);
|
||||
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
||||
);
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
||||
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
||||
ReturnType<typeof findSportsSeasonById>
|
||||
>
|
||||
);
|
||||
});
|
||||
|
||||
it("clears placements even when no match has been played", async () => {
|
||||
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
||||
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
||||
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
||||
// because upsertParticipantResult refuses to un-finalize a result.
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
||||
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(sportsSeasonId).toBe("season-1");
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
||||
});
|
||||
|
||||
it("scopes the wipe to this bracket, never the whole season", async () => {
|
||||
// A season-wide delete would take every other event's placements with it, with only
|
||||
// this bracket's replay able to rebuild them.
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).not.toContain("p3");
|
||||
});
|
||||
|
||||
it("passes each participant once when a team appears in more than one slot", async () => {
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toHaveLength(3);
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("skips empty slots rather than passing nulls through", async () => {
|
||||
setMatches([
|
||||
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
||||
]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("still takes the season-wide delete for a qualifying event", async () => {
|
||||
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
||||
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
||||
// on purpose and rebuilds QP from the bracket.
|
||||
setEvent({ isQualifyingEvent: true });
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no bracket rather than wiping anything", async () => {
|
||||
setMatches([]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.error).toContain("No bracket to reprocess");
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -44,6 +44,7 @@ import {
|
|||
setParticipantResult,
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
} from "~/models/participant-result";
|
||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
|
|
@ -171,7 +172,7 @@ async function scoreQualifyingBracket(
|
|||
/**
|
||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
||||
* affected leagues' Discord channels. Returns the number of participants marked.
|
||||
* affected leagues' Discord channels.
|
||||
*
|
||||
* "Newly eliminated" = participants with no prior result row, so re-running a
|
||||
* generation step never re-announces the same teams. The announcement is a
|
||||
|
|
@ -179,11 +180,18 @@ async function scoreQualifyingBracket(
|
|||
* the eliminations themselves are already committed. eventId is deliberately
|
||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
||||
* completed matches as "Scored Matches".
|
||||
*
|
||||
* Returns the number of participants marked alongside whether a standings recalculation
|
||||
* actually ran. The caller banks entry floors before calling this and needs them to
|
||||
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
||||
* because the recalc is skipped for qualifying events, when every eliminated team
|
||||
* already had a result row (the second run of a generation), and when the announcement
|
||||
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
||||
*/
|
||||
async function markEliminatedAndAnnounce(
|
||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||
participantIds: string[]
|
||||
): Promise<number> {
|
||||
): Promise<{ markedCount: number; recalculated: boolean }> {
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
||||
|
|
@ -192,6 +200,8 @@ async function markEliminatedAndAnnounce(
|
|||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
|
||||
let recalculated = false;
|
||||
|
||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
||||
try {
|
||||
|
|
@ -199,12 +209,13 @@ async function markEliminatedAndAnnounce(
|
|||
eventName: event.name ?? undefined,
|
||||
eliminatedParticipantIds: newlyEliminatedIds,
|
||||
});
|
||||
recalculated = true;
|
||||
} catch (err) {
|
||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return participantIds.length;
|
||||
return { markedCount: participantIds.length, recalculated };
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
|
|
@ -423,14 +434,17 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const toEliminate = allParticipants
|
||||
.filter((p) => !participantsInBracket.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
||||
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
||||
|
||||
// markEliminatedAndAnnounce recalculates standings only when it actually
|
||||
// eliminated somebody. When the bracket field is the whole season (nothing to
|
||||
// eliminate) the entry floors above would never reach teamStandings.totalPoints,
|
||||
// so recalculate here. skipDiscord: seeding floors are not a result to announce.
|
||||
if (toEliminate.length === 0 && entryFloorCount > 0) {
|
||||
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
||||
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
||||
// others: not for a qualifying event, not when every eliminated team already had
|
||||
// a result row (the second run of a generation, since the first wrote 0 for all
|
||||
// of them), not when there was nobody to eliminate, and not when the announcement
|
||||
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
||||
// skipDiscord: seeding floors are not a result to announce.
|
||||
if (entryFloorCount > 0 && !recalculated) {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
skipDiscord: true,
|
||||
|
|
@ -910,19 +924,31 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "No bracket to reprocess" };
|
||||
}
|
||||
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
||||
// only the partial rows would leave stale finalized ones, which the "never
|
||||
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
||||
//
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so this
|
||||
// wipes every event's placements in the season and only the replay below can
|
||||
// rebuild them (the same hazard clear-bracket documents). With nothing to
|
||||
// replay there is nothing to rebuild from, so skip it entirely: applying entry
|
||||
// floors and re-marking eliminations below is additive and needs no wipe.
|
||||
// Scoped to the participants this bracket actually holds, not the whole season:
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so a
|
||||
// season-wide delete takes every other event's placements with it and only this
|
||||
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
||||
//
|
||||
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
||||
// regenerate → reprocess repair path: the discarded bracket's finalized
|
||||
// placements are exactly what needs clearing, and there is always something to
|
||||
// rebuild from — the entry floors below, then the replay.
|
||||
const db = database();
|
||||
if (completed.length > 0) {
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
await deleteParticipantResultsForParticipants(
|
||||
event.sportsSeasonId,
|
||||
[...bracketParticipantIds],
|
||||
db
|
||||
);
|
||||
|
||||
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
||||
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
||||
|
|
@ -968,11 +994,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!bracketParticipantIds.has(participant.id)) {
|
||||
|
|
@ -1178,7 +1199,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const toEliminate = allParticipants
|
||||
.filter((p) => !uniqueParticipants.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate);
|
||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||
groupsEvent,
|
||||
toEliminate
|
||||
);
|
||||
|
||||
return {
|
||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue