Fix three defects found reviewing the bracket entry-floor work
All three predate the EV fix on this branch and were surfaced by a review
of the full main..HEAD range.
1. reprocess-bracket skipped its wipe exactly when it was needed.
The wipe was guarded on `completed.length > 0`, but clear-bracket
deliberately leaves placements alone and tells the admin to "Run
Reprocess Bracket after rebuilding to clear the placements those
results produced". After clear then regenerate nothing is completed,
so the wipe was skipped and the discarded bracket's finalized
placements survived — and upsertParticipantResult's never-un-finalize
guard then stopped the entry floors and the replay from correcting
them. The advertised recovery path could not work.
The guard was not arbitrary: seasonParticipantResults is keyed by
sports season, not by event, so a season-wide delete takes every
other event's placements with it. Rather than flip the condition,
narrow the delete. New deleteParticipantResultsForParticipants scopes
it to the participants the bracket actually holds, which removes the
collateral damage the guard was defending against, so the delete can
run unconditionally. The participant set was already being computed
further down for the elimination pass; it is now built once and
reused. The qualifying branch keeps its season-wide delete, which is
deliberate and rebuilds via finalizeQualifyingPoints.
2. Banked entry floors could miss teamStandings.totalPoints.
generate-bracket recalculated standings only when `toEliminate` was
empty, assuming markEliminatedAndAnnounce covers every other case. It
does not — it recalculates only when the event is non-qualifying AND
somebody was *newly* eliminated, i.e. had no prior result row. So the
second run of a generation (the first wrote 0 for every non-bracket
participant) recalculated nowhere, and neither did a qualifying event
with teams to eliminate. The floors never reached the standings.
markEliminatedAndAnnounce now returns { markedCount, recalculated }
and the caller drives off that fact instead of re-deriving it, which
also covers the case where the announcement threw — the catch
swallows the error, and a failed recalc is precisely when the
fallback should run.
3. The NBA mobile pager fell back to index geometry.
Its BracketTreePaginated was the only one of five call sites not
forwarding feeders/template, so mobile rendered "TBD" where desktop
rendered "Winner of ...".
Tests: reprocess wipes on a bracket with nothing played, stays scoped to
the bracket, dedupes and skips empty slots, and leaves the qualifying
path alone; generate recalculates in each of the four gaps above and
still does not double-recalculate; and the NBA layout gives its mobile
pane the same slot labels as desktop. Each was confirmed to fail against
the previous behavior.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EmUdy42Rpgx9qZTnpQwirz
This commit is contained in:
parent
7ec17e417d
commit
1e215c3ac9
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