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(), draftedParticipantIds: new Set(), 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(), 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; 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(); 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(); expect( screen.getByPlaceholderText("Search participants...") ).toBeInTheDocument(); }); it("renders the Show Drafted toggle", () => { render(); expect(screen.getByText("Show Drafted")).toBeInTheDocument(); }); it("renders Show Ineligible and Show drafted sports toggles when hasTeam=true", () => { render( ); 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( ); expect(screen.queryByText("Show Ineligible")).not.toBeInTheDocument(); }); it("renders empty state when no participants", () => { render( ); 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(); // 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( ); 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( ); 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( ); 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( ); 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( ); expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); }); it("does not render dividers when projectedPicks is empty", () => { render( ); expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); }); it("skips dividers where picksFromNow exceeds list length", () => { render( ); 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( ); 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( ); 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( ); expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); }); it("suppresses dividers when sport filter is active", () => { render( ); expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); }); it("suppresses dividers when hideDrafted is active", () => { render( ); expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); }); }); describe("Sport Filter - sheet/popover", () => { it("opens and shows checkboxes for each sport", async () => { render(); 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(); 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( ); 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(); 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( ); 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( ); 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(); 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( ); 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( ()} /> ); const buttons = screen.getAllByRole('button', { name: "Add to watchlist" }); expect(buttons.length).toBeGreaterThan(0); }); it("renders EyeOff icon for watched participants", () => { render( ); 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( ()} /> ); 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( ); const highlighted = container.querySelector(".bg-emerald-500\\/10"); expect(highlighted).toBeInTheDocument(); }); it("does not highlight unwatched participants", () => { const { container } = render( ()} /> ); const highlighted = container.querySelector(".bg-emerald-500\\/10"); expect(highlighted).not.toBeInTheDocument(); }); it("shows Watched Only checkbox when hasTeam", () => { render( ); expect(screen.getByText("Watched Only")).toBeInTheDocument(); }); it("does not show Watched Only checkbox when hasTeam is false", () => { render( ); expect(screen.queryByText("Watched Only")).not.toBeInTheDocument(); }); it("disables Watched Only checkbox when no participants are watched", () => { render( ()} /> ); expect(screen.getByLabelText("Watched Only")).toBeDisabled(); }); it("calls onShowOnlyWatchedChange when Watched Only checkbox clicked", async () => { const onShowOnlyWatchedChange = vi.fn(); render( ); 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( ); 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( ); expect(screen.getAllByText("Not enough NFL participants for remaining teams").length).toBeGreaterThan(0); }); }); });