Show visual dividers in the available participants list indicating where your future draft picks fall relative to the current pick position. Dividers are suppressed when any filter is active (search, sport filter, hide drafted, etc.) since positional references are meaningless in a filtered view. - Add getProjectedPicks() to draft-order.ts for computing future pick positions in a snake draft - Interleave divider items into the virtualized list in AvailableParticipantsSection - Extract ListItem type to module scope - Add tests for getProjectedPicks, divider rendering, and filter suppression
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
|
|
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(),
|
|
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 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", async () => {
|
|
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
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
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("Done button closes the sheet", async () => {
|
|
render(<AvailableParticipantsSection {...defaultProps} />);
|
|
await clickFirstSportFilterTrigger("Filter by sport: All Sports");
|
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
|
await user.click(screen.getByRole("button", { name: "Done" }));
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|