258 lines
9.6 KiB
TypeScript
258 lines
9.6 KiB
TypeScript
import { render, screen, within } from "@testing-library/react";
|
|
import { CtePlanViewer } from "./CtePlanViewer";
|
|
import type { CtePlanV2 } from "./artifactV2";
|
|
|
|
const plan: CtePlanV2 = {
|
|
schema_version: 2,
|
|
question: "Quanti pazienti hanno avuto un impianto negli ultimi 5 anni?",
|
|
strategy: "Filtriamo prima i pazienti idonei, poi joiniamo gli impianti.",
|
|
ctes: [
|
|
{
|
|
name: "pazienti_idonei",
|
|
index: 1,
|
|
purpose: "Seleziona i pazienti con follow-up attivo",
|
|
rationale: "Base della catena: riduce il volume prima dei join",
|
|
depends_on: [],
|
|
tables: [{ name: "pazienti", description: "Anagrafica pazienti" }],
|
|
keys: ["paziente_id"],
|
|
filters: [
|
|
{
|
|
column: "pazienti.stato",
|
|
op: "IS",
|
|
value: "TRUE",
|
|
description: "Flag attivo",
|
|
rationale: "Solo pazienti in follow-up",
|
|
},
|
|
{
|
|
column: "pazienti.data_ultimo_follow_up",
|
|
op: ">=",
|
|
value: "CURRENT_DATE - INTERVAL '5 years'",
|
|
},
|
|
],
|
|
output_columns: ["paziente_id", "data_nascita"],
|
|
},
|
|
{
|
|
name: "impianti_recenti",
|
|
index: 2,
|
|
purpose: "Filtra gli impianti recenti",
|
|
depends_on: ["pazienti_idonei"],
|
|
tables: [{ name: "impianti" }],
|
|
filters: [
|
|
{
|
|
column: "impianti.data_impianto",
|
|
op: ">=",
|
|
value: "CURRENT_DATE - INTERVAL '5 years'",
|
|
},
|
|
],
|
|
output_columns: ["impianto_id"],
|
|
},
|
|
],
|
|
};
|
|
|
|
function getCteCard(name: string): HTMLElement {
|
|
const heading = screen.getByRole("heading", { level: 3, name });
|
|
const card = heading.closest<HTMLElement>('[data-slot="card"]');
|
|
if (!card) throw new Error(`Missing card for CTE ${name}`);
|
|
return card;
|
|
}
|
|
|
|
test("renders strategy, question, and a chain strip with CTE names in order", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
expect(screen.getByText("Question")).toBeInTheDocument();
|
|
expect(screen.getByText("Strategy")).toBeInTheDocument();
|
|
expect(screen.getByText("Execution order")).toBeInTheDocument();
|
|
expect(screen.getByText(plan.question!)).toBeInTheDocument();
|
|
expect(screen.getByText(plan.strategy!)).toBeInTheDocument();
|
|
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
|
|
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("uses compact CTE section rhythm with moderate lateral padding", () => {
|
|
const { container } = render(<CtePlanViewer plan={plan} />);
|
|
for (const header of container.querySelectorAll('[data-slot="card-header"]')) {
|
|
expect(header).toHaveClass("gap-2", "px-3", "py-2", "sm:px-4", "[&.border-b]:pb-2");
|
|
expect(header).not.toHaveClass("gap-3", "p-4", "sm:p-5");
|
|
}
|
|
for (const content of container.querySelectorAll('[data-slot="card-content"]')) {
|
|
expect(content).toHaveClass("gap-3", "px-3", "py-2", "sm:px-4");
|
|
expect(content).not.toHaveClass("gap-5", "p-4", "sm:p-5");
|
|
}
|
|
});
|
|
|
|
test("renders the CTE cards as ordered plan steps", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
const list = screen.getByRole("list", { name: "CTE plan steps" });
|
|
const steps = within(list).getAllByRole("listitem");
|
|
|
|
expect(steps).toHaveLength(2);
|
|
expect(within(steps[0]).getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
"pazienti_idonei"
|
|
);
|
|
expect(within(steps[1]).getByRole("heading", { level: 3 })).toHaveTextContent(
|
|
"impianti_recenti"
|
|
);
|
|
expect(steps[0].querySelector('[data-slot="card"]')).toBeInTheDocument();
|
|
expect(steps[1].querySelector('[data-slot="card"]')).toBeInTheDocument();
|
|
});
|
|
|
|
test("renders one card per CTE with index badge, purpose, and filter value", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
expect(screen.getByText("CTE 1/2")).toBeInTheDocument();
|
|
expect(screen.getByText("CTE 2/2")).toBeInTheDocument();
|
|
expect(screen.getByText("Seleziona i pazienti con follow-up attivo")).toBeInTheDocument();
|
|
expect(screen.getByText(/pazienti\.stato/)).toBeInTheDocument();
|
|
expect(screen.getByText(/TRUE/)).toBeInTheDocument();
|
|
});
|
|
|
|
test("renders each filter as aligned column, operator, and value fields", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
const filter = within(getCteCard("pazienti_idonei")).getByRole("group", {
|
|
name: "Filter 1",
|
|
});
|
|
expect(within(filter).getByText("Column")).toBeInTheDocument();
|
|
expect(within(filter).getByText("Operator")).toBeInTheDocument();
|
|
expect(within(filter).getByText("Value")).toBeInTheDocument();
|
|
expect(within(filter).getByText("pazienti.stato")).toBeInTheDocument();
|
|
expect(within(filter).getByText("IS")).toBeInTheDocument();
|
|
expect(within(filter).getByText("TRUE")).toBeInTheDocument();
|
|
});
|
|
|
|
test("groups filters in one bordered divided list with accessible padded rows", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
const cases = [
|
|
{ card: getCteCard("pazienti_idonei"), count: 2 },
|
|
{ card: getCteCard("impianti_recenti"), count: 1 },
|
|
];
|
|
const lists: HTMLElement[] = [];
|
|
|
|
for (const { card, count } of cases) {
|
|
const filters = within(card).getAllByRole("group", { name: /Filter \d/ });
|
|
expect(filters).toHaveLength(count);
|
|
const list = filters[0].parentElement;
|
|
expect(list).not.toBeNull();
|
|
expect(list).toHaveClass("border", "divide-y");
|
|
for (const filter of filters) {
|
|
expect(filter.parentElement).toBe(list);
|
|
expect(filter).toHaveClass("px-3", "py-1", "sm:px-4");
|
|
expect(filter).not.toHaveClass("p-3", "sm:p-4");
|
|
}
|
|
lists.push(list!);
|
|
}
|
|
|
|
expect(lists[0]).not.toBe(lists[1]);
|
|
});
|
|
|
|
test("renders rationale as a plain top-divided section", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
|
|
const rationale = screen
|
|
.getByText("Base della catena: riduce il volume prima dei join")
|
|
.closest("section");
|
|
expect(rationale).toHaveClass("border-t");
|
|
expect(rationale).not.toHaveClass("border-l-2", "bg-primary/5", "rounded-lg");
|
|
});
|
|
|
|
test("compacts table, filter, detail, and rationale rows", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
const card = getCteCard("pazienti_idonei");
|
|
const tableRow = within(card).getByText("pazienti").parentElement;
|
|
expect(tableRow).toHaveClass("px-3", "py-1", "sm:px-4");
|
|
|
|
const filter = within(card).getByRole("group", { name: "Filter 1" });
|
|
expect(filter).toHaveClass("space-y-2", "px-3", "py-1", "sm:px-4");
|
|
expect(filter.firstElementChild).toHaveClass("gap-2");
|
|
const filterDetails = within(card).getByText("Solo pazienti in follow-up").parentElement;
|
|
expect(filterDetails).toHaveClass("gap-y-1", "pt-1");
|
|
|
|
const rationale = within(card)
|
|
.getByText("Base della catena: riduce il volume prima dei join")
|
|
.closest("section");
|
|
expect(rationale).toHaveClass("pt-1");
|
|
expect(within(rationale!).getByText("Rationale")).toHaveClass("mb-1");
|
|
});
|
|
|
|
test("shows 'No dependencies' for a CTE with an empty depends_on", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("renders depends_on chips for a CTE that has dependencies", () => {
|
|
render(<CtePlanViewer plan={plan} />);
|
|
// "pazienti_idonei" appears both as chain-strip entry and as a depends_on chip
|
|
// for impianti_recenti — assert at least one occurrence beyond the strip.
|
|
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(1);
|
|
});
|
|
|
|
test("omits sections for missing optional fields without rendering 'undefined'", () => {
|
|
const minimalPlan: CtePlanV2 = {
|
|
schema_version: 2,
|
|
ctes: [{ name: "minimal", index: 1 }],
|
|
};
|
|
|
|
render(<CtePlanViewer plan={minimalPlan} />);
|
|
|
|
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Question")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Strategy")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Purpose")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Tables")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Filters")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Keys")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Output columns")).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Rationale")).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("allows long CTE, table, filter, and chip values to wrap safely", () => {
|
|
const longName = "cte_" + "extremely_long_unbroken_name_".repeat(6);
|
|
const longTable = "schema." + "extremely_long_table_name_".repeat(6);
|
|
const longColumn = "schema.table." + "extremely_long_filter_column_".repeat(5);
|
|
const longOperator = "EXTREMELY_LONG_OPERATOR_WITHOUT_BREAKS";
|
|
const longValue = "value_" + "extremely_long_unbroken_value_".repeat(6);
|
|
const longChip = "column_" + "extremely_long_unbroken_chip_".repeat(6);
|
|
const longPlan: CtePlanV2 = {
|
|
schema_version: 2,
|
|
ctes: [
|
|
{
|
|
name: longName,
|
|
index: 1,
|
|
depends_on: [longChip],
|
|
tables: [{ name: longTable }],
|
|
keys: [longChip],
|
|
filters: [{ column: longColumn, op: longOperator, value: longValue }],
|
|
output_columns: [longChip],
|
|
},
|
|
],
|
|
};
|
|
|
|
render(<CtePlanViewer plan={longPlan} />);
|
|
|
|
expect(screen.getByRole("heading", { level: 3, name: longName })).toHaveClass(
|
|
"min-w-0",
|
|
"[overflow-wrap:anywhere]"
|
|
);
|
|
expect(screen.getByText(longTable)).toHaveClass(
|
|
"min-w-0",
|
|
"whitespace-pre-wrap",
|
|
"[overflow-wrap:anywhere]"
|
|
);
|
|
expect(screen.getByText(longColumn)).toHaveClass("min-w-0", "[overflow-wrap:anywhere]");
|
|
expect(screen.getByText(longOperator)).toHaveClass("min-w-0", "[overflow-wrap:anywhere]");
|
|
expect(screen.getByText(longValue)).toHaveClass(
|
|
"min-w-0",
|
|
"whitespace-pre-wrap",
|
|
"[overflow-wrap:anywhere]"
|
|
);
|
|
for (const chip of screen.getAllByText(longChip)) {
|
|
expect(chip).toHaveClass(
|
|
"min-w-0",
|
|
"whitespace-pre-wrap",
|
|
"[overflow-wrap:anywhere]"
|
|
);
|
|
}
|
|
});
|