import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { server } from "./mocks/server"; import { errorHandlers, setMockJobDuration } from "./mocks/handlers"; import App from "./App"; // Mock NiiVue to avoid WebGL in tests vi.mock("@niivue/niivue", () => ({ Niivue: class MockNiivue { attachToCanvas = vi.fn(); loadVolumes = vi.fn().mockResolvedValue(undefined); cleanup = vi.fn(); gl = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })), }; opts = {}; }, })); describe("App Integration", () => { // Use real timers for integration tests - fake timers don't sync well // with MSW's async handlers and polling intervals beforeEach(() => { // Reset mock job duration to fast for tests setMockJobDuration(500); // Jobs complete in 500ms }); afterEach(() => { setMockJobDuration(500); // Reset to default }); describe("Initial Render", () => { it("renders main heading", () => { render(); expect( screen.getByRole("heading", { name: /stroke lesion segmentation/i }), ).toBeInTheDocument(); }); it("renders case selector", async () => { render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); }); it("renders run button", () => { render(); expect( screen.getByRole("button", { name: /run segmentation/i }), ).toBeInTheDocument(); }); it("shows placeholder viewer message", () => { render(); expect( screen.getByText(/select a case and run segmentation/i), ).toBeInTheDocument(); }); }); describe("Run Button State", () => { it("disables run button when no case selected", async () => { render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); expect( screen.getByRole("button", { name: /run segmentation/i }), ).toBeDisabled(); }); it("enables run button when case selected", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); expect( screen.getByRole("button", { name: /run segmentation/i }), ).toBeEnabled(); }); }); describe("Segmentation Flow", () => { it("shows processing state when running", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Button should show "Processing..." while job is running expect( screen.getByRole("button", { name: /processing/i }), ).toBeInTheDocument(); }); it("shows progress indicator during job execution", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Progress indicator should appear during processing await waitFor(() => { expect(screen.getByRole("progressbar")).toBeInTheDocument(); }); }); it("displays metrics after successful segmentation", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for job to complete (mock duration is 500ms, polling is 2s) // Use 5s timeout to account for polling interval await waitFor( () => { expect(screen.getByText("0.847")).toBeInTheDocument(); }, { timeout: 5000 }, ); expect(screen.getByText("15.32 mL")).toBeInTheDocument(); }); it("displays viewer after successful segmentation", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for job to complete and canvas to render await waitFor( () => { expect(document.querySelector("canvas")).toBeInTheDocument(); }, { timeout: 5000 }, ); }); it("hides placeholder after successful segmentation", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for job to complete await waitFor( () => { expect(screen.getByText("0.847")).toBeInTheDocument(); }, { timeout: 5000 }, ); expect( screen.queryByText(/select a case and run segmentation/i), ).not.toBeInTheDocument(); }); it("shows cancel button during processing", async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); expect( screen.getByRole("button", { name: /cancel/i }), ).toBeInTheDocument(); }); }); describe("Error Handling", () => { it("shows error when job creation fails", async () => { server.use(errorHandlers.segmentCreateError); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); expect(screen.getByText(/failed to create job/i)).toBeInTheDocument(); }); it("allows retry after error", async () => { server.use(errorHandlers.segmentCreateError); const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); await user.selectOptions(screen.getByRole("combobox"), "sub-stroke0001"); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); // Reset to success handler server.resetHandlers(); // Retry await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for job to complete (real timer now) await waitFor( () => { expect(screen.getByText("0.847")).toBeInTheDocument(); }, { timeout: 5000 }, ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); }); describe("Multiple Runs", () => { it( "allows running segmentation on different cases", { timeout: 15000 }, async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByRole("combobox")).toBeInTheDocument(); }); // First case await user.selectOptions( screen.getByRole("combobox"), "sub-stroke0001", ); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for first segmentation to complete - check metrics (Dice Score proves completion) await waitFor( () => { expect(screen.getByText("0.847")).toBeInTheDocument(); // Button should no longer say "Processing..." after completion expect( screen.queryByRole("button", { name: /processing/i }), ).not.toBeInTheDocument(); }, { timeout: 5000 }, ); // Second case await user.selectOptions( screen.getByRole("combobox"), "sub-stroke0002", ); await user.click( screen.getByRole("button", { name: /run segmentation/i }), ); // Wait for second job to complete - check that case ID changed in metrics // Note: We look within the metrics container for the case ID to avoid matching dropdown await waitFor( () => { // The metrics panel shows case ID in a span with class "ml-2 font-mono" // after the "Case:" label const caseLabels = screen.getAllByText(/Case:/i); expect(caseLabels.length).toBeGreaterThan(0); // The second run should show sub-stroke0002 in the metrics const metricsContainer = screen.getByText("Results").closest("div"); expect(metricsContainer).toHaveTextContent("sub-stroke0002"); }, { timeout: 5000 }, ); }, ); }); });