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 },
);
},
);
});
});