174 lines
6.4 KiB
JavaScript
174 lines
6.4 KiB
JavaScript
// Soft-validators for the v2 artifact payloads (contracts.md A/B/C). Pure, no I/O.
|
|
// A payload without schema_version===2 is NOT an error -- it's legacy, and the caller
|
|
// falls back to the current (pre-v2) rendering path.
|
|
const test = require("node:test");
|
|
const assert = require("node:assert");
|
|
const {
|
|
validateCtePlanV2,
|
|
validateCteResultThin,
|
|
validatePhaseSummaryV2,
|
|
} = require("../core/artifact-contracts.js");
|
|
|
|
// --- validateCtePlanV2 (payload A) ------------------------------------------------
|
|
|
|
test("validateCtePlanV2: legacy (no schema_version) is ok:true, legacy:true", () => {
|
|
const out = validateCtePlanV2({ some: "legacy shape" });
|
|
assert.deepEqual(out, { ok: true, legacy: true });
|
|
});
|
|
|
|
test("validateCtePlanV2: legacy when data is a plain string (markdown)", () => {
|
|
const out = validateCtePlanV2("### Piano CTE\n1. foo");
|
|
assert.deepEqual(out, { ok: true, legacy: true });
|
|
});
|
|
|
|
test("validateCtePlanV2: valid v2 payload", () => {
|
|
const out = validateCtePlanV2({
|
|
schema_version: 2,
|
|
question: "domanda",
|
|
strategy: "razionale",
|
|
ctes: [
|
|
{ name: "base_pazienti", purpose: "p", rationale: "r", depends_on: [], tables: [], keys: [], filters: [], output_columns: [] },
|
|
{ name: "eventi", purpose: "p2", rationale: "r2", depends_on: ["base_pazienti"], tables: [], keys: [], filters: [], output_columns: [] },
|
|
],
|
|
});
|
|
assert.equal(out.ok, true);
|
|
assert.ok(!out.errors || out.errors.length === 0);
|
|
});
|
|
|
|
test("validateCtePlanV2: rejects missing ctes array", () => {
|
|
const out = validateCtePlanV2({ schema_version: 2, question: "q", strategy: "s" });
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /ctes/.test(e)));
|
|
});
|
|
|
|
test("validateCtePlanV2: rejects a CTE missing name", () => {
|
|
const out = validateCtePlanV2({
|
|
schema_version: 2, question: "q", strategy: "s",
|
|
ctes: [{ purpose: "p" }],
|
|
});
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /ctes\[0\].*name/.test(e)));
|
|
});
|
|
|
|
test("validateCtePlanV2: rejects depends_on referencing an unknown/forward CTE", () => {
|
|
const out = validateCtePlanV2({
|
|
schema_version: 2, question: "q", strategy: "s",
|
|
ctes: [
|
|
{ name: "a", depends_on: ["b"] }, // b does not precede a
|
|
{ name: "b", depends_on: [] },
|
|
],
|
|
});
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /depends_on/.test(e) && /'b'/.test(e)));
|
|
});
|
|
|
|
test("validateCtePlanV2: depends_on referencing an earlier CTE is fine", () => {
|
|
const out = validateCtePlanV2({
|
|
schema_version: 2, question: "q", strategy: "s",
|
|
ctes: [
|
|
{ name: "a", depends_on: [] },
|
|
{ name: "b", depends_on: ["a"] },
|
|
],
|
|
});
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
// --- validateCteResultThin (payload B, model side is THIN) ------------------------
|
|
|
|
test("validateCteResultThin: legacy (string/markdown) is ok:true, legacy:true", () => {
|
|
assert.deepEqual(validateCteResultThin("**CTE risultato**: ok"), { ok: true, legacy: true });
|
|
assert.deepEqual(validateCteResultThin({ some: "legacy" }), { ok: true, legacy: true });
|
|
});
|
|
|
|
test("validateCteResultThin: valid thin v2 payload (all fields optional)", () => {
|
|
const out = validateCteResultThin({ schema_version: 2 });
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
test("validateCteResultThin: valid thin v2 payload with purpose/rationale/note", () => {
|
|
const out = validateCteResultThin({
|
|
schema_version: 2, purpose: "seleziona pazienti", rationale: "base per la catena", note: "ok",
|
|
});
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
test("validateCteResultThin: rejects non-string purpose/rationale/note", () => {
|
|
const out = validateCteResultThin({ schema_version: 2, purpose: 42 });
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /purpose/.test(e)));
|
|
});
|
|
|
|
test("validateCteResultThin: permissive on extra fields (e.g. a model that also sends sql) -- guidance against this lives in SKILL.md, not the validator", () => {
|
|
const out = validateCteResultThin({ schema_version: 2, sql: "WITH a AS (SELECT 1)" });
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
// --- validatePhaseSummaryV2 (payload C) -------------------------------------------
|
|
|
|
test("validatePhaseSummaryV2: legacy (no schema_version) is ok:true, legacy:true", () => {
|
|
assert.deepEqual(validatePhaseSummaryV2({ recap: "legacy text" }), { ok: true, legacy: true });
|
|
assert.deepEqual(validatePhaseSummaryV2("legacy markdown"), { ok: true, legacy: true });
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: valid minimal v2 payload", () => {
|
|
const out = validatePhaseSummaryV2({ schema_version: 2, summary: "riepilogo" });
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: valid full v2 payload", () => {
|
|
const out = validatePhaseSummaryV2({
|
|
schema_version: 2,
|
|
summary: "riepilogo",
|
|
checks: [{ label: "schema valido", status: "ok" }],
|
|
sections: [{ title: "Criteri", items: [{ label: "flag attivo", table: "t", column: "c", value: "TRUE", kind: "filter" }] }],
|
|
tables: [{ name: "t", role: "promoted", columns: [{ name: "c" }] }],
|
|
open_questions: [],
|
|
});
|
|
assert.equal(out.ok, true);
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: rejects missing summary", () => {
|
|
const out = validatePhaseSummaryV2({ schema_version: 2 });
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /summary/.test(e)));
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: rejects a check with an invalid status", () => {
|
|
const out = validatePhaseSummaryV2({
|
|
schema_version: 2, summary: "s",
|
|
checks: [{ label: "x", status: "maybe" }],
|
|
});
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /checks\[0\].*status/.test(e)));
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: rejects a section missing title or items", () => {
|
|
const out = validatePhaseSummaryV2({
|
|
schema_version: 2, summary: "s",
|
|
sections: [{ items: [] }],
|
|
});
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((e) => /sections\[0\].*title/.test(e)));
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: rejects structured open_questions instead of crashing the UI", () => {
|
|
const out = validatePhaseSummaryV2({
|
|
schema_version: 2,
|
|
summary: "s",
|
|
open_questions: [
|
|
{
|
|
label: "pazienti_finale restituisce 0 righe",
|
|
question: "Verificare se i filtri sono troppo restrittivi",
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(out.ok, false);
|
|
assert.ok(out.errors.some((error) => /open_questions\[0\]/.test(error)));
|
|
});
|
|
|
|
test("validatePhaseSummaryV2: permissive on extra unknown fields", () => {
|
|
const out = validatePhaseSummaryV2({ schema_version: 2, summary: "s", extra_field_from_model: "whatever" });
|
|
assert.equal(out.ok, true);
|
|
});
|