Adds live standings sync and display for bracket-based sports (NBA/NHL), so league members can see W/L tables and which teams their opponents drafted during the regular season — not just after the playoff bracket is set. - New `regular_season_standings` table with upsert-on-conflict sync - Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters, externalId write-back for future syncs, and unmatched-team resolution UI - `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes, playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll - Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page - Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings - Show standings above bracket until matches exist; below once bracket is set - `normalize-team-name` utility extracted to shared lib Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
182 lines
5.9 KiB
TypeScript
182 lines
5.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import { RegularSeasonStandings } from "../RegularSeasonStandings";
|
|
|
|
function makeRow(overrides: Partial<{
|
|
id: string;
|
|
participantId: string;
|
|
wins: number;
|
|
losses: number;
|
|
otLosses: number | null;
|
|
gamesPlayed: number;
|
|
winPct: string | null;
|
|
gamesBack: string | null;
|
|
conference: string | null;
|
|
division: string | null;
|
|
conferenceRank: number | null;
|
|
divisionRank: number | null;
|
|
leagueRank: number | null;
|
|
streak: string | null;
|
|
lastTen: string | null;
|
|
homeRecord: string | null;
|
|
awayRecord: string | null;
|
|
syncedAt: string | null;
|
|
participant: { id: string; name: string; shortName?: string | null };
|
|
}> = {}) {
|
|
return {
|
|
id: overrides.id ?? "row-1",
|
|
participantId: overrides.participantId ?? "p-1",
|
|
wins: overrides.wins ?? 40,
|
|
losses: overrides.losses ?? 28,
|
|
otLosses: overrides.otLosses ?? null,
|
|
gamesPlayed: overrides.gamesPlayed ?? 68,
|
|
winPct: overrides.winPct ?? "0.5882",
|
|
gamesBack: overrides.gamesBack ?? null,
|
|
conference: overrides.conference ?? null,
|
|
division: overrides.division ?? null,
|
|
conferenceRank: overrides.conferenceRank ?? null,
|
|
divisionRank: overrides.divisionRank ?? null,
|
|
leagueRank: overrides.leagueRank ?? 1,
|
|
streak: overrides.streak ?? null,
|
|
lastTen: overrides.lastTen ?? null,
|
|
homeRecord: overrides.homeRecord ?? null,
|
|
awayRecord: overrides.awayRecord ?? null,
|
|
syncedAt: overrides.syncedAt ?? null,
|
|
participant: overrides.participant ?? { id: "p-1", name: "Boston Celtics" },
|
|
};
|
|
}
|
|
|
|
const NO_OWNERSHIPS = {};
|
|
const NO_USER_IDS: string[] = [];
|
|
|
|
describe("RegularSeasonStandings", () => {
|
|
it("renders nothing when standings array is empty", () => {
|
|
const { container } = render(
|
|
<RegularSeasonStandings
|
|
standings={[]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
expect(container.firstChild).toBeNull();
|
|
});
|
|
|
|
it("renders team names", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[
|
|
makeRow({ participant: { id: "p-1", name: "Boston Celtics" } }),
|
|
makeRow({ id: "row-2", participantId: "p-2", participant: { id: "p-2", name: "Toronto Raptors" } }),
|
|
]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
expect(screen.getByText("Boston Celtics")).toBeInTheDocument();
|
|
expect(screen.getByText("Toronto Raptors")).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows OTL column when showOtLosses=true", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ otLosses: 5 })]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
showOtLosses={true}
|
|
/>
|
|
);
|
|
expect(screen.getByText("OTL")).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides OTL column when showOtLosses=false (default)", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow()]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
expect(screen.queryByText("OTL")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders conference headings and inline division labels when present", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[
|
|
makeRow({ conference: "Eastern", division: "Atlantic", conferenceRank: 1, participant: { id: "p-1", name: "Boston Celtics" } }),
|
|
makeRow({ id: "row-2", participantId: "p-2", conference: "Western", division: "Pacific", conferenceRank: 1, participant: { id: "p-2", name: "LA Lakers" } }),
|
|
]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
// Conference group headings are rendered as <h3>
|
|
expect(screen.getByText(/Eastern Conference/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/Western Conference/i)).toBeInTheDocument();
|
|
// Division is now shown as an inline label per row (not a section header)
|
|
expect(screen.getAllByText(/Atlantic/i).length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText(/Pacific/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("shows ownership badge for drafted teams", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ participantId: "p-1" })]}
|
|
teamOwnerships={{
|
|
"p-1": { teamName: "Team Alpha", ownerName: "Alice", teamId: "t-1" },
|
|
}}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
expect(screen.getByText("Alice")).toBeInTheDocument();
|
|
});
|
|
|
|
it("does not show badge for undrafted teams", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ participantId: "p-1" })]}
|
|
teamOwnerships={{}}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
// No owner badge should be present
|
|
expect(screen.queryByText(/Alice/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("applies different styling for user's own team", () => {
|
|
const { container } = render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ participantId: "p-1" })]}
|
|
teamOwnerships={{}}
|
|
userParticipantIds={["p-1"]}
|
|
/>
|
|
);
|
|
// The user's row should have a primary styling class
|
|
const userRow = container.querySelector("tr.bg-primary\\/5");
|
|
expect(userRow).not.toBeNull();
|
|
});
|
|
|
|
it("renders streak with correct color class for wins", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ streak: "W5" })]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
const streakEl = screen.getByText("W5");
|
|
expect(streakEl.className).toContain("emerald");
|
|
});
|
|
|
|
it("renders streak with destructive class for losses", () => {
|
|
render(
|
|
<RegularSeasonStandings
|
|
standings={[makeRow({ streak: "L3" })]}
|
|
teamOwnerships={NO_OWNERSHIPS}
|
|
userParticipantIds={NO_USER_IDS}
|
|
/>
|
|
);
|
|
const streakEl = screen.getByText("L3");
|
|
expect(streakEl.className).toContain("destructive");
|
|
});
|
|
});
|