brackt/app/components/__tests__/AvailableParticipantsSection.test.tsx
Claude 52277ab864
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Failing after 2m43s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix AvailableParticipantsSection test: use fireEvent to avoid Radix UI timer hang
Replace async userEvent.click with fireEvent.click for the sport filter
dropdown test when hasTeam=true. The extra Checkbox components rendered in
that path stall userEvent's act() settlement loop inside Radix UI, causing
the test to hang. fireEvent bypasses the async machinery while still
exercising the open/close behavior.

https://claude.ai/code/session_012ACmUs2vAwEF9jZu7Guos2
2026-06-09 21:48:43 +00:00

560 lines
19 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({
count,
estimateSize,
}: {
count: number;
estimateSize: (index: number) => number;
}) => {
const size = estimateSize(0);
const items = Array.from({ length: count }, (_, i) => ({
index: i,
start: i * size,
size,
key: i,
}));
return {
getTotalSize: () => count * size,
getVirtualItems: () => items,
measureElement: vi.fn(),
};
},
}));
const defaultProps = {
participants: [
{ id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } },
{ id: "2", name: "Player B", sport: { id: "s2", name: "NBA" } },
{ id: "3", name: "Player C", sport: { id: "s1", name: "NFL" } },
],
searchQuery: "",
sportFilters: [] as string[],
hideDrafted: false,
hideIneligible: false,
hideCompletedSports: false,
uniqueSports: ["NBA", "NFL"],
userDraftedSportNames: new Set<string>(),
draftedParticipantIds: new Set<string>(),
queue: [] as Array<{ id: string; participantId: string }>,
eligibility: null,
canPick: false,
hasTeam: false,
onSearchChange: vi.fn(),
onSportFiltersChange: vi.fn(),
onHideDraftedChange: vi.fn(),
onHideIneligibleChange: vi.fn(),
onHideCompletedSportsChange: vi.fn(),
onMakePick: vi.fn(),
onAddToQueue: vi.fn(),
onRemoveFromQueue: vi.fn(),
watchedParticipantIds: new Set<string>(),
onToggleWatchlist: vi.fn(),
showOnlyWatched: false,
onShowOnlyWatchedChange: vi.fn(),
pickAnimationSignal: null,
participantRanks: new Map([
["1", { overallRank: 1, sportRank: 1 }],
["2", { overallRank: 2, sportRank: 1 }],
["3", { overallRank: 3, sportRank: 2 }],
]),
};
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom
// (CSS breakpoints are not applied). Click the first one to open the Sheet.
let user: ReturnType<typeof userEvent.setup>;
function createReason(
code: DraftIneligibilityReason["code"],
message: string
): DraftIneligibilityReason {
return { code, message };
}
function clickFirstSportFilterTrigger(name: string | RegExp) {
return user.click(screen.getAllByRole("button", { name })[0]);
}
describe("AvailableParticipantsSection", () => {
beforeEach(() => {
vi.clearAllMocks();
user = userEvent.setup();
});
describe("Rendering", () => {
it("renders all participants by name", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(screen.getAllByText("Player A").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player B").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player C").length).toBeGreaterThan(0);
});
it("renders search input", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(
screen.getByPlaceholderText("Search participants...")
).toBeInTheDocument();
});
it("renders the Show Drafted toggle", () => {
render(<AvailableParticipantsSection {...defaultProps} />);
expect(screen.getByText("Show Drafted")).toBeInTheDocument();
});
it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
eligibility={{ eligibleSportIds: new Set(["s1"]), ineligibleReasons: {} }}
/>
);
expect(screen.getByText("Show Ineligible")).toBeInTheDocument();
// Show drafted sports lives inside the sport filter dropdown.
// Use fireEvent instead of user.click to avoid Radix UI timer hang when
// hasTeam=true renders extra Checkbox components that stall userEvent's
// act() settlement loop.
fireEvent.click(screen.getAllByRole("button", { name: "Filter by sport: All Sports" })[0]);
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Show drafted sports")).toBeInTheDocument();
});
it("does not render Show Ineligible when hasTeam=false", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={false}
eligibility={{ eligibleSportIds: new Set(), ineligibleReasons: {} }}
/>
);
expect(screen.queryByText("Show Ineligible")).not.toBeInTheDocument();
});
it("renders empty state when no participants", () => {
render(
<AvailableParticipantsSection {...defaultProps} participants={[]} />
);
expect(screen.getAllByText("No participants found.").length).toBeGreaterThan(0);
});
});
describe("Sport Filter - trigger label", () => {
it('shows "All Sports" in aria-label when no filters selected', () => {
render(<AvailableParticipantsSection {...defaultProps} />);
// Both mobile and desktop triggers share the same label
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: All Sports",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("shows sport name in aria-label when exactly one filter selected", () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: NFL",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("shows count in aria-label when multiple filters selected", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
const triggers = screen.getAllByRole("button", {
name: "Filter by sport: 2 sports selected",
});
expect(triggers.length).toBeGreaterThanOrEqual(1);
});
it("does not render active-filter badges", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
expect(screen.queryByLabelText(/Remove .* filter/)).not.toBeInTheDocument();
});
});
describe("Projected Pick Dividers", () => {
const manyParticipants = Array.from({ length: 30 }, (_, i) => ({
id: String(i + 1),
name: `Player ${i + 1}`,
sport: { id: "s1", name: "NFL" },
}));
const manyRanks = new Map(
Array.from({ length: 30 }, (_, i) => [
String(i + 1),
{ overallRank: i + 1, sportRank: i + 1 },
])
);
it("renders projected pick divider at correct position", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
projectedPicks={[
{ round: 3, picksFromNow: 10 },
{ round: 4, picksFromNow: 20 },
]}
/>
);
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
expect(screen.getByText("Projected Round 4 Pick")).toBeInTheDocument();
});
it("does not render dividers when projectedPicks is undefined", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
/>
);
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
});
it("does not render dividers when projectedPicks is empty", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
projectedPicks={[]}
/>
);
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
});
it("skips dividers where picksFromNow exceeds list length", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
projectedPicks={[
{ round: 3, picksFromNow: 10 },
{ round: 10, picksFromNow: 100 },
]}
/>
);
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
expect(screen.queryByText("Projected Round 10 Pick")).not.toBeInTheDocument();
});
it("skips dividers where picksFromNow is zero or negative", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
projectedPicks={[
{ round: 1, picksFromNow: 0 },
{ round: 2, picksFromNow: 5 },
]}
/>
);
expect(screen.queryByText("Projected Round 1 Pick")).not.toBeInTheDocument();
expect(screen.getByText("Projected Round 2 Pick")).toBeInTheDocument();
});
it("still renders all participants alongside dividers", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
/>
);
expect(screen.getAllByText("Player 1").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player 10").length).toBeGreaterThan(0);
expect(screen.getAllByText("Player 30").length).toBeGreaterThan(0);
});
it("suppresses dividers when search query is active", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
searchQuery="Player"
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
/>
);
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
});
it("suppresses dividers when sport filter is active", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
sportFilters={["NFL"]}
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
/>
);
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
});
it("suppresses dividers when hideDrafted is active", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
participants={manyParticipants}
participantRanks={manyRanks}
hideDrafted
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
/>
);
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
});
});
describe("Sport Filter - sheet/popover", () => {
it("opens and shows checkboxes for each sport", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("NBA")).toBeInTheDocument();
expect(within(dialog).getByText("NFL")).toBeInTheDocument();
});
it("calls onSportFiltersChange with sport added when checkbox clicked", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByLabelText("NFL"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NFL"]);
});
it("calls onSportFiltersChange with sport removed when unchecked", async () => {
render(
<AvailableParticipantsSection
{...defaultProps}
sportFilters={["NFL", "NBA"]}
/>
);
await clickFirstSportFilterTrigger("Filter by sport: 2 sports selected");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByLabelText("NFL"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith(["NBA"]);
});
it('does not show "Reset" when no filters active', async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
const dialog = screen.getByRole("dialog");
expect(within(dialog).queryByText("Reset")).not.toBeInTheDocument();
});
it('shows "Reset" button when filters exist', async () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
await clickFirstSportFilterTrigger("Filter by sport: NFL");
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Reset")).toBeInTheDocument();
});
it('calls onSportFiltersChange([]) and onHideCompletedSportsChange(false) when "Reset" clicked', async () => {
render(
<AvailableParticipantsSection {...defaultProps} sportFilters={["NFL"]} />
);
await clickFirstSportFilterTrigger("Filter by sport: NFL");
const dialog = screen.getByRole("dialog");
await user.click(within(dialog).getByText("Reset"));
expect(defaultProps.onSportFiltersChange).toHaveBeenCalledWith([]);
expect(defaultProps.onHideCompletedSportsChange).toHaveBeenCalledWith(false);
});
it("Popover trigger opens and can be closed", async () => {
render(<AvailableParticipantsSection {...defaultProps} />);
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("Watchlist", () => {
it("does not render watchlist toggle when hasTeam is false", () => {
render(
<AvailableParticipantsSection {...defaultProps} hasTeam={false} />
);
expect(screen.queryByRole('button', { name: "Add to watchlist" })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: "Remove from watchlist" })).not.toBeInTheDocument();
});
it("renders EyeOff icon for unwatched participants when hasTeam is true", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set<string>()}
/>
);
const buttons = screen.getAllByRole('button', { name: "Add to watchlist" });
expect(buttons.length).toBeGreaterThan(0);
});
it("renders EyeOff icon for watched participants", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set(["1"])}
/>
);
const buttons = screen.getAllByRole('button', { name: "Remove from watchlist" });
expect(buttons.length).toBeGreaterThan(0);
});
it("calls onToggleWatchlist when eye button clicked", async () => {
const onToggleWatchlist = vi.fn();
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
onToggleWatchlist={onToggleWatchlist}
watchedParticipantIds={new Set<string>()}
/>
);
const buttons = screen.getAllByRole('button', { name: "Add to watchlist" });
await user.click(buttons[0]);
expect(onToggleWatchlist).toHaveBeenCalledWith("1");
});
it("applies emerald highlight to watched participants", () => {
const { container } = render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set(["1"])}
/>
);
const highlighted = container.querySelector(".bg-emerald-500\\/10");
expect(highlighted).toBeInTheDocument();
});
it("does not highlight unwatched participants", () => {
const { container } = render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set<string>()}
/>
);
const highlighted = container.querySelector(".bg-emerald-500\\/10");
expect(highlighted).not.toBeInTheDocument();
});
it("shows Watched Only checkbox when hasTeam", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
/>
);
expect(screen.getByText("Watched Only")).toBeInTheDocument();
});
it("does not show Watched Only checkbox when hasTeam is false", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={false}
/>
);
expect(screen.queryByText("Watched Only")).not.toBeInTheDocument();
});
it("disables Watched Only checkbox when no participants are watched", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set<string>()}
/>
);
expect(screen.getByLabelText("Watched Only")).toBeDisabled();
});
it("calls onShowOnlyWatchedChange when Watched Only checkbox clicked", async () => {
const onShowOnlyWatchedChange = vi.fn();
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
watchedParticipantIds={new Set(["1"])}
onShowOnlyWatchedChange={onShowOnlyWatchedChange}
/>
);
await user.click(screen.getByLabelText("Watched Only"));
expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true);
});
});
describe("Ineligible reasons", () => {
it("renders inline out-of-flex-spots copy for ineligible participants", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
canPick={true}
eligibility={{
eligibleSportIds: new Set(["s2"]),
ineligibleReasons: {
s1: createReason("no_flex_spots", "Out of flex spots (2/2)"),
},
}}
/>
);
expect(screen.getAllByText("Out of flex spots (2/2)").length).toBeGreaterThan(0);
expect(screen.getAllByTitle("Out of flex spots (2/2)").length).toBeGreaterThan(0);
});
it("renders inline no-extras copy for ineligible participants", () => {
render(
<AvailableParticipantsSection
{...defaultProps}
hasTeam={true}
canPick={true}
eligibility={{
eligibleSportIds: new Set(["s2"]),
ineligibleReasons: {
s1: createReason("no_extra_participants", "Not enough NFL participants for remaining teams"),
},
}}
/>
);
expect(screen.getAllByText("Not enough NFL participants for remaining teams").length).toBeGreaterThan(0);
});
});
});