Files
ThothII/frontend/src/shell/SessionDocumentsPanel.test.tsx
T

210 lines
9.4 KiB
TypeScript

import { render, screen, within } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
import type { SessionSummary } from "../api/types";
function makeClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
function wrap(ui: React.ReactElement, client = makeClient()) {
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return render(ui, { wrapper: Wrapper });
}
const base: SessionSummary = {
id: "s1", status: "open", question: "How many ablations?", summary: null,
created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null,
name: null, group: null, archived: false,
};
beforeEach(() => {
server.use(http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "How many ablations?" },
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
]),
));
});
test("renders the document titles from the API", async () => {
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByRole("heading", { name: "Original question" })).toBeInTheDocument();
// "Final SQL" is both the section heading and the SqlViewer block label — target the heading.
expect(screen.getByRole("heading", { name: "Final SQL" })).toBeInTheDocument();
});
test("lets the Final SQL document use the full resized panel width", async () => {
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} desktopSplit />);
const sqlSection = (await screen.findByRole("heading", { name: "Final SQL" })).closest("section");
expect(sqlSection).toHaveClass("w-full");
expect(sqlSection?.parentElement).toHaveClass("w-full");
expect(sqlSection?.parentElement).not.toHaveClass("max-w-[76ch]");
});
test("lets text documents adapt to the full resized panel width", async () => {
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} desktopSplit />);
const questionSection = (
await screen.findByRole("heading", { name: "Original question" })
).closest("section");
expect(questionSection).toHaveClass("w-full");
expect(questionSection).not.toHaveClass("max-w-[76ch]");
});
test("shows Resume for resumable, hides it for finalized", async () => {
const { rerender } = wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByRole("button", { name: /resume/i })).toBeInTheDocument();
rerender(<SessionDocumentsPanel session={{ ...base, status: "finalized" }} onClose={vi.fn()} onResume={vi.fn()} />);
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
});
test("hides Resume for an archived session", async () => {
wrap(<SessionDocumentsPanel session={{ ...base, archived: true }} onClose={vi.fn()} onResume={vi.fn()} />);
await screen.findByText("Original question");
expect(screen.queryByRole("button", { name: /resume/i })).not.toBeInTheDocument();
});
test("a malformed document renders a fallback without taking down its siblings", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Valid JSON but missing `candidates` — SchemaLinkingViewer crashes on this shape.
// Without a per-document boundary this crash blanks the whole panel (React 18
// unmounts the tree); the good sibling document must still render.
// resetHandlers(...) replaces the beforeEach handler so this response is used.
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "F4", key: "schema", title: "Schema linking", format: "schema-linking", content: '{"joins":[]}' },
{ phase: "—", key: "question", title: "Original question", format: "text", content: "SIBLING-SURVIVES" },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
// The healthy sibling rendered — the panel did not white-screen.
expect(await screen.findByText("SIBLING-SURVIVES")).toBeInTheDocument();
// The broken document degraded to a fallback in place.
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByText(/couldn't be displayed/i)).toBeInTheDocument();
errorSpy.mockRestore();
});
test("renders the canonical summary order and formats human text as Markdown", async () => {
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "—", key: "question", title: "Original question", format: "text", content: "Original **question**" },
{ phase: "F7", key: "sql", title: "Final SQL", format: "sql", content: "SELECT 1" },
{ phase: "finalize", key: "preview", title: "Data preview", format: "markdown", content: "| count |\n|---|\n| 42 |" },
{ phase: "F3", key: "revised_question", title: "Revised question", format: "markdown", content: "# Domanda\n\nConta i pazienti **attivi**." },
{ phase: "F3", key: "assumptions", title: "Assumptions", format: "markdown", content: "## Assunzioni\n\n- Solo adulti" },
{ phase: "F8", key: "memories", title: "Memories", format: "memories", content: "[]" },
{ phase: "F4", key: "schema_linking", title: "Schema linking", format: "schema-linking", content: '{"question":"q","candidates":[],"joins":[],"excluded":[],"open_questions":[]}' },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
const panel = await screen.findByRole("complementary", { name: /session summary/i });
await within(panel).findByRole("heading", { name: "Original question", level: 3 });
expect(within(panel).getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual([
"Original question",
"Final SQL",
"Data preview",
"Revised question",
"Assumptions",
"Memories",
"Schema linking",
]);
expect(screen.getByText("question").tagName).toBe("STRONG");
expect(screen.getByText("attivi").tagName).toBe("STRONG");
expect(screen.getByRole("heading", { name: "Domanda", level: 1 })).toBeInTheDocument();
expect(screen.getByText("Solo adulti").closest("li")).toBeInTheDocument();
});
test("renders one approved-then-declined memory list with Markdown details", async () => {
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{
phase: "F8",
key: "memories",
title: "Memories",
format: "memories",
content: JSON.stringify([
{
status: "approved",
subject: "ablazione **valida**",
detail: "Stato `completed`.",
rationale: "*Confermato* dal reviewer.",
},
{
status: "declined",
subject: "paziente attivo",
detail: "Usa `flag_attivo`.",
rationale: "",
},
]),
},
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
const list = await screen.findByRole("list", { name: /memories/i });
const items = within(list).getAllByRole("listitem");
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent(/approved/i);
expect(items[1]).toHaveTextContent(/declined/i);
expect(within(items[0]).getByText("valida").tagName).toBe("STRONG");
expect(within(items[0]).getByText("completed").tagName).toBe("CODE");
expect(within(items[0]).getByText("Confermato").tagName).toBe("EM");
expect(screen.queryByText(/memory promoted/i)).not.toBeInTheDocument();
});
test("never renders technical approval, promotion, or memory decisions", async () => {
const lines = [
{ type: "phase_approved", subject: "phase:1" },
{ type: "table_approved", subject: "fact_a" },
{ type: "table_promoted", subject: "fact_a" },
{ type: "column_promoted", subject: "fact_a.id" },
{ type: "cte_approved", subject: "eventi_device" },
{ type: "sql_approved", subject: "phase:7" },
{ type: "datamart_requested", subject: "phase:8" },
{ type: "datamart_declined", subject: "phase:8" },
{ type: "memory_promoted", subject: "concetto" },
{ type: "column_excluded", subject: "fact_a.note", detail: "Non **pertinente**" },
].map((decision) => JSON.stringify(decision)).join("\n");
server.resetHandlers(
http.get("http://localhost:8787/sessions/s1/documents", () =>
HttpResponse.json([
{ phase: "—", key: "decisions", title: "Decisions", format: "decisions", content: lines },
]),
),
);
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
expect(await screen.findByText("column excluded")).toBeInTheDocument();
expect(screen.getByText("pertinente").tagName).toBe("STRONG");
for (const hidden of [
"phase approved",
"table approved",
"table promoted",
"column promoted",
"cte approved",
"sql approved",
"datamart requested",
"datamart declined",
"memory promoted",
]) {
expect(screen.queryByText(hidden)).not.toBeInTheDocument();
}
});