diff --git a/frontend/src/viewers/CteResultViewer.test.tsx b/frontend/src/viewers/CteResultViewer.test.tsx index e56508c1..5b8fb6b9 100644 --- a/frontend/src/viewers/CteResultViewer.test.tsx +++ b/frontend/src/viewers/CteResultViewer.test.tsx @@ -76,6 +76,16 @@ test("shows empty-state when preview has no rows", async () => { expect(screen.getByText(/no preview rows/i)).toBeInTheDocument(); }); +test("shows empty-state, no crash, when preview.rows is null (pre-upgrade records)", async () => { + const nullRows = { + ...okResult, + preview: { columns: ["paziente_id"], rows: null as unknown as (string | number)[][] }, + }; + render(); + await screen.findByTestId("hl"); + expect(screen.getByText(/no preview rows/i)).toBeInTheDocument(); +}); + test("renders columns definition list with descriptions", async () => { const { container } = render(); await screen.findByTestId("hl"); diff --git a/frontend/src/viewers/CteResultViewer.tsx b/frontend/src/viewers/CteResultViewer.tsx index 23f8f2a7..7dc167b4 100644 --- a/frontend/src/viewers/CteResultViewer.tsx +++ b/frontend/src/viewers/CteResultViewer.tsx @@ -1,16 +1,11 @@ import { Badge } from "../components/ui/badge"; import { SqlViewer, type SqlBlock } from "./SqlViewer"; import { PreviewGrid } from "./PreviewGrid"; +import { statusBadgeClass } from "./statusBadge"; import type { CteResultV2 } from "./artifactV2"; const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90"; -function statusBadgeVariant(status: string): "default" | "destructive" | "outline" { - if (status === "ok") return "default"; - if (status === "error") return "destructive"; - return "outline"; -} - function statusLabel(status: string): string { if (status === "ok") return "success"; if (status === "error") return "error"; @@ -24,7 +19,7 @@ function testStatus(status: string): SqlBlock["testStatus"] { } export function CteResultViewer({ result }: { result: CteResultV2 }) { - const hasPreviewRows = !!result.preview && result.preview.rows.length > 0; + const hasPreviewRows = (result.preview?.rows?.length ?? 0) > 0; const descriptions = (result.columns ?? []).map((c) => c.description ?? ""); const showColumns = descriptions.some((d) => d.trim() !== ""); const dependsOn = result.depends_on ?? []; @@ -42,7 +37,7 @@ export function CteResultViewer({ result }: { result: CteResultV2 }) { CTE {result.index}/{result.total} {result.name} - {statusLabel(result.status)} + {statusLabel(result.status)} {result.execution_ms !== undefined && ( {result.execution_ms} ms )} diff --git a/frontend/src/viewers/PhaseSummaryViewer.test.tsx b/frontend/src/viewers/PhaseSummaryViewer.test.tsx index d3380f1a..fc33a001 100644 --- a/frontend/src/viewers/PhaseSummaryViewer.test.tsx +++ b/frontend/src/viewers/PhaseSummaryViewer.test.tsx @@ -81,6 +81,16 @@ test("renders open_questions as a bullet list", () => { expect(screen.getByText("Serve confermare la finestra temporale?")).toBeInTheDocument(); }); +test("renders a section with title only (no items key) without crashing", () => { + const proseOnly: PhaseSummaryV2 = { + schema_version: 2, + phase: { id: "F5", num: 5, name: "sintesi" }, + sections: [{ title: "Note libere" }], + }; + render(); + expect(screen.getByText("Note libere")).toBeInTheDocument(); +}); + test("omits open_questions section when empty and shows no 'undefined' text", () => { const minimal: PhaseSummaryV2 = { schema_version: 2, diff --git a/frontend/src/viewers/PhaseSummaryViewer.tsx b/frontend/src/viewers/PhaseSummaryViewer.tsx index 51fc8a35..6badb2cd 100644 --- a/frontend/src/viewers/PhaseSummaryViewer.tsx +++ b/frontend/src/viewers/PhaseSummaryViewer.tsx @@ -1,19 +1,14 @@ import { Badge } from "../components/ui/badge"; import { MarkdownView } from "./MarkdownView"; +import { statusBadgeClass } from "./statusBadge"; import type { PhaseCheck, PhaseSection, PhaseSectionItem, PhaseSummaryV2, PhaseTable } from "./artifactV2"; const CODE_CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90"; -function checkBadgeVariant(status: string): "default" | "destructive" | "outline" { - if (status === "ok") return "default"; - if (status === "fail") return "destructive"; - return "outline"; -} - function Check({ check }: { check: PhaseCheck }) { return (
- {check.status} + {check.status}
{check.label} {check.detail && {check.detail}} @@ -42,7 +37,7 @@ function Section({ section }: { section: PhaseSection }) {

{section.title}

- {section.items.map((item, i) => ( + {(section.items ?? []).map((item, i) => ( ))}
@@ -55,7 +50,7 @@ function TableRecap({ table }: { table: PhaseTable }) {
{table.name} - {table.role} + {table.role}
{table.description &&

{table.description}

} {table.columns && table.columns.length > 0 && ( diff --git a/frontend/src/viewers/SqlViewer.tsx b/frontend/src/viewers/SqlViewer.tsx index 3e371cdf..8778b551 100644 --- a/frontend/src/viewers/SqlViewer.tsx +++ b/frontend/src/viewers/SqlViewer.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { highlightSql } from "./highlight"; +import { statusBadgeClass } from "./statusBadge"; export interface SqlBlock { name: string; @@ -17,12 +18,6 @@ const STATUS_LABELS: Record = { untested: "untested", }; -const STATUS_CLASSES: Record = { - passed: "bg-[oklch(var(--success)/0.15)] text-[oklch(0.45_0.12_165)] ring-1 ring-[oklch(var(--success)/0.3)]", - failed: "bg-destructive/10 text-destructive ring-1 ring-destructive/25", - untested: "bg-muted text-muted-foreground ring-1 ring-border", -}; - function Block({ block }: { block: SqlBlock }) { const [open, setOpen] = useState(true); const [html, setHtml] = useState(""); @@ -39,7 +34,7 @@ function Block({ block }: { block: SqlBlock }) { const badge = block.testStatus ? STATUS_LABELS[block.testStatus] : null; const badgeClass = block.testStatus - ? STATUS_CLASSES[block.testStatus] + ? statusBadgeClass(block.testStatus) : ""; return ( diff --git a/frontend/src/viewers/artifactV2.ts b/frontend/src/viewers/artifactV2.ts index 4dee0963..caca0e09 100644 --- a/frontend/src/viewers/artifactV2.ts +++ b/frontend/src/viewers/artifactV2.ts @@ -94,7 +94,7 @@ export interface PhaseSectionItem { export interface PhaseSection { title: string; - items: PhaseSectionItem[]; + items?: PhaseSectionItem[]; } export interface PhaseTableColumn { diff --git a/frontend/src/viewers/statusBadge.ts b/frontend/src/viewers/statusBadge.ts new file mode 100644 index 00000000..3338536a --- /dev/null +++ b/frontend/src/viewers/statusBadge.ts @@ -0,0 +1,25 @@ +// Shared status → badge class mapping so success/warning/error colors can't drift +// between SqlViewer, CteResultViewer and PhaseSummaryViewer. +// ok/success/passed/promoted -> green (--success) +// warn/untested-with-warning -> amber (--warning) +// error/failed -> destructive/red +// anything else -> neutral outline +const SUCCESS_STATUSES = new Set(["ok", "success", "passed", "promoted"]); +const WARN_STATUSES = new Set(["warn", "warning"]); +const ERROR_STATUSES = new Set(["error", "failed", "fail"]); + +export const STATUS_BADGE_CLASSES = { + success: + "bg-[oklch(var(--success)/0.15)] text-[oklch(0.45_0.12_165)] ring-1 ring-[oklch(var(--success)/0.3)]", + warning: + "bg-[oklch(var(--warning)/0.15)] text-[oklch(0.46_0.11_79)] ring-1 ring-[oklch(var(--warning)/0.4)]", + error: "bg-destructive/10 text-destructive ring-1 ring-destructive/25", + neutral: "bg-muted text-muted-foreground ring-1 ring-border", +} as const; + +export function statusBadgeClass(status: string): string { + if (SUCCESS_STATUSES.has(status)) return STATUS_BADGE_CLASSES.success; + if (WARN_STATUSES.has(status)) return STATUS_BADGE_CLASSES.warning; + if (ERROR_STATUSES.has(status)) return STATUS_BADGE_CLASSES.error; + return STATUS_BADGE_CLASSES.neutral; +} diff --git a/harness/.pi/extensions/gate/__tests__/enrich.test.js b/harness/.pi/extensions/gate/__tests__/enrich.test.js index 6a4bfdf6..b5394f61 100644 --- a/harness/.pi/extensions/gate/__tests__/enrich.test.js +++ b/harness/.pi/extensions/gate/__tests__/enrich.test.js @@ -132,6 +132,15 @@ test("buildCteResultV2 builds preview from last_test.columns/preview_rows", () = assert.deepEqual(out.warnings, []); }); +test("buildCteResultV2: last_test.preview_rows null (pre-upgrade ok records) -> preview.rows is []", () => { + const cteInfo = { + name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null, + last_test: { status: "ok", execution_ms: 1, row_sample: 1, warnings: [], sql_hash: "h", columns: ["cod_paz"], preview_rows: null }, + }; + const out = buildCteResultV2({ schema_version: 2 }, cteInfo); + assert.deepEqual(out.preview.rows, []); +}); + test("buildCteResultV2: columns[] carries name, with description left as an empty placeholder (filled separately by the gate)", () => { const cteInfo = { name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null, diff --git a/harness/.pi/extensions/gate/enrich.js b/harness/.pi/extensions/gate/enrich.js index fd629d0f..f90e47bf 100644 --- a/harness/.pi/extensions/gate/enrich.js +++ b/harness/.pi/extensions/gate/enrich.js @@ -85,7 +85,7 @@ function buildCteResultV2(thinData, cteInfo) { warnings: lt.warnings || [], sql_hash: lt.sql_hash, columns: (lt.columns || []).map((name) => ({ name, description: "" })), - preview: { columns: lt.columns || [], rows: lt.preview_rows || null }, + preview: { columns: lt.columns || [], rows: lt.preview_rows || [] }, note: thin.note, }; }