feat(viewer): ArtifactView routes artifact.data by kind (schema/sql/cte/question)
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ArtifactView } from "./ArtifactView";
|
||||
|
||||
vi.mock("./mermaid", () => ({
|
||||
renderMermaid: vi.fn().mockResolvedValue('<svg data-testid="mm"></svg>'),
|
||||
}));
|
||||
|
||||
test("sql artifact renders the SQL text", () => {
|
||||
render(<ArtifactView artifact={{ kind: "sql", data: "SELECT 1" }} />);
|
||||
expect(screen.getByText("SELECT 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("cte_plan renders an ordered list of names", () => {
|
||||
render(<ArtifactView artifact={{ kind: "cte_plan", data: { names: ["a_cte", "b_cte"] } }} />);
|
||||
expect(screen.getByText("a_cte")).toBeInTheDocument();
|
||||
expect(screen.getByText("b_cte")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("question renders markdown headings", () => {
|
||||
render(<ArtifactView artifact={{ kind: "question", data: "## Domanda\nrevised" }} />);
|
||||
expect(screen.getByText("Domanda")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("unknown kind falls back to formatted JSON", () => {
|
||||
render(<ArtifactView artifact={{ kind: "mystery", data: { a: 1 } }} />);
|
||||
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("schema_linking renders the schema viewer", async () => {
|
||||
render(
|
||||
<ArtifactView
|
||||
artifact={{
|
||||
kind: "schema_linking",
|
||||
data: { candidates: [{ kind: "table", name: "orders", decision: "promoted" }], joins: [] },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(await screen.findByTestId("mm")).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { SqlViewer, type SqlBlock } from "./SqlViewer";
|
||||
import { SchemaLinkingViewer, type SchemaLinking } from "./SchemaLinkingViewer";
|
||||
import { MarkdownView } from "./MarkdownView";
|
||||
|
||||
type ArtifactData = { kind: string; data?: unknown; content?: unknown; [k: string]: unknown };
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
// The payload lives in `data` (harness contract); fall back to `content`, then the
|
||||
// artifact object itself, so older/other producers still render something.
|
||||
function payload(artifact: ArtifactData): unknown {
|
||||
if (artifact.data !== undefined) return artifact.data;
|
||||
if (artifact.content !== undefined) return artifact.content;
|
||||
return artifact;
|
||||
}
|
||||
|
||||
function toSchemaLinking(raw: unknown): SchemaLinking | null {
|
||||
let obj: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try { obj = JSON.parse(raw); } catch { return null; }
|
||||
}
|
||||
const rec = asRecord(obj);
|
||||
if (!rec || !Array.isArray(rec.candidates)) return null;
|
||||
return {
|
||||
candidates: rec.candidates as SchemaLinking["candidates"],
|
||||
joins: Array.isArray(rec.joins) ? (rec.joins as SchemaLinking["joins"]) : [],
|
||||
excluded: Array.isArray(rec.excluded) ? (rec.excluded as SchemaLinking["excluded"]) : [],
|
||||
open_questions: Array.isArray(rec.open_questions) ? (rec.open_questions as string[]) : [],
|
||||
question: typeof rec.question === "string" ? rec.question : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toSqlBlocks(raw: unknown): SqlBlock[] | null {
|
||||
if (typeof raw === "string") return [{ name: "SQL", sql: raw }];
|
||||
const rec = asRecord(raw);
|
||||
if (!rec) return null;
|
||||
if (typeof rec.sql === "string") return [{ name: "SQL", sql: rec.sql }];
|
||||
if (Array.isArray(rec.ctes)) {
|
||||
const blocks = rec.ctes
|
||||
.map((c) => asRecord(c))
|
||||
.filter((c): c is Record<string, unknown> => !!c && typeof c.sql === "string")
|
||||
.map((c) => ({ name: typeof c.name === "string" ? c.name : "cte", sql: c.sql as string }));
|
||||
if (typeof rec.final === "string") blocks.push({ name: "final", sql: rec.final });
|
||||
return blocks.length ? blocks : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toMarkdown(raw: unknown): string | null {
|
||||
if (typeof raw === "string") return raw;
|
||||
const rec = asRecord(raw);
|
||||
if (!rec) return null;
|
||||
for (const k of ["markdown", "question", "text"]) {
|
||||
if (typeof rec[k] === "string") return rec[k] as string;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function cteNames(raw: unknown): string[] | null {
|
||||
if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
|
||||
const rec = asRecord(raw);
|
||||
if (rec && Array.isArray(rec.names) && rec.names.every((x) => typeof x === "string")) {
|
||||
return rec.names as string[];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function JsonFallback({ value }: { value: unknown }): ReactElement {
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
return (
|
||||
<pre className="overflow-auto whitespace-pre-wrap rounded bg-muted p-3 text-sm">{text}</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactView({ artifact }: { artifact: ArtifactData }): ReactElement {
|
||||
const kind = artifact.kind ?? "";
|
||||
const data = payload(artifact);
|
||||
|
||||
if (kind === "schema_linking") {
|
||||
const linking = toSchemaLinking(data);
|
||||
if (linking) return <SchemaLinkingViewer linking={linking} />;
|
||||
}
|
||||
if (kind === "sql" || kind === "cte_result") {
|
||||
const blocks = toSqlBlocks(data);
|
||||
if (blocks) return <SqlViewer blocks={blocks} />;
|
||||
}
|
||||
if (kind === "cte_plan") {
|
||||
const names = cteNames(data);
|
||||
if (names) {
|
||||
return (
|
||||
<ol className="list-decimal pl-6 text-sm">
|
||||
{names.map((n, i) => (
|
||||
<li key={`${n}-${i}`} className="font-mono">{n}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (kind === "question" || kind === "phase") {
|
||||
const md = toMarkdown(data);
|
||||
if (md !== null) return <MarkdownView source={md} />;
|
||||
}
|
||||
return <JsonFallback value={data} />;
|
||||
}
|
||||
Reference in New Issue
Block a user