feat(harness): pure widget-builder functions + JS golden/fuzzy tests (D2, L1)

The gate's widget-descriptor CONSTRUCTION, extracted into pure testable functions.
Each builder turns plain params into a ui_request descriptor (spec §4.1 taxonomy):
buildSelectRequest, buildMultiselectRequest, buildArtifactGate, buildInfoRequest,
buildFreetextRequest, withChildLinkage. No Pi context, no I/O -- the part of the
gate fully testable in L1 (in JS, in-language, no Python mirror).

Validation in the builders (not just happy-path): select requires title + array
options; multiselect allow_empty:false with zero options throws (a broken widget);
artifact-gate requires an artifact with a kind + a valid action.kind
(confirm/approve_reject/view_only); info level must be info/warning/error. The
Altro escape hatch with freetext linkage is always injected on blocking pick
widgets (no-limbo invariant).

L1: 14 node:test cases -- 3 golden files (select_F1, multiselect_F4,
artifact_gate_F5) pin the exact descriptor shape; fuzzy tests assert bad params
throw clearly rather than silently producing a broken widget.

package.json wires 'npm test' -> node --test (runs alongside pytest). The gate
GLUE (emission, anti-bypass, no-limbo loop) is C2, verified at L2.
This commit is contained in:
2026-06-26 23:07:56 +02:00
parent e6eeb2ae3e
commit 7971d73628
6 changed files with 393 additions and 0 deletions
@@ -0,0 +1,161 @@
// L1 tests for the pure widget-descriptor builders (spec D2/D4, §4.1).
// Each builder turns plain params into a ui_request descriptor object -- no Pi
// context, no I/O. Golden files pin the exact shape; fuzzy tests check that bad
// params throw clearly rather than silently produce a broken widget.
// Run: npm test (node --test)
const test = require("node:test");
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const {
buildSelectRequest,
buildMultiselectRequest,
buildArtifactGate,
buildInfoRequest,
buildFreetextRequest,
withChildLinkage,
} = require("../builders.js");
const GOLDEN = path.join(__dirname, "golden");
const golden = (name) => JSON.parse(fs.readFileSync(path.join(GOLDEN, name)));
// --- golden shape tests -------------------------------------------------------
test("select F1 matches golden shape", () => {
const result = buildSelectRequest({
id: "u1", phase: "F1", title: "Disambigua 'ablazione'",
options: [{ id: "o1", label: "procedura" }, { id: "o2", label: "patologia" }],
});
const g = golden("select_F1.json");
assert.equal(result.widget, "select");
assert.equal(result.type, "ui_request");
assert.equal(result.schema_version, 1);
assert.deepEqual(result.reserved, g.reserved); // back/exit/other framework hatches
assert.deepEqual(
result.options.filter((o) => o.id !== "other"),
g.options.filter((o) => o.id !== "other"),
);
});
test("select always carries the Altro escape hatch with freetext linkage", () => {
const result = buildSelectRequest({
id: "u", phase: "F1", title: "x",
options: [{ id: "a", label: "A" }],
});
const other = result.options.find((o) => o.id === "other");
assert.ok(other, "Altro option must always be present");
assert.equal(other.opens.widget, "freetext");
});
test("select surfaces a recommended marker when given", () => {
const result = buildSelectRequest({
id: "u", phase: "F1", title: "x",
options: [{ id: "a", label: "A" }, { id: "b", label: "B" }],
recommended: "b",
});
assert.equal(result.recommended, "b");
});
test("multiselect F4 matches golden + carries content + allowEmpty", () => {
// a multiselect with allow_empty:false MUST have options (zero options is a
// broken widget); the golden uses allow_empty:true for the empty-options case
// and allow_empty:false when options are present.
const result = buildMultiselectRequest({
id: "u3", phase: "F4", title: "Tabelle",
options: [{ id: "dim_pazienti", label: "dim_pazienti" }],
content: { candidates: [] }, allowEmpty: false,
});
const g = golden("multiselect_F4.json");
assert.equal(result.widget, "multiselect");
assert.equal(result.allow_empty, false);
assert.ok("content" in result);
assert.deepEqual(result.reserved, g.reserved);
});
test("multiselect with allow_empty:true accepts zero options", () => {
const result = buildMultiselectRequest({
id: "u", phase: "F4", title: "x", options: [], allowEmpty: true,
});
assert.equal(result.allow_empty, true);
assert.deepEqual(result.options, []);
});
test("multiselect with allowEmpty:false and zero options throws", () => {
assert.throws(
() => buildMultiselectRequest({ id: "u", phase: "F4", title: "x", options: [], allowEmpty: false }),
/allow_empty|options/i,
);
});
test("artifact-gate F5 schema matches golden", () => {
const result = buildArtifactGate({
id: "u2", phase: "F5", title: "Schema-linking",
artifact: { kind: "schema_linking", data: { candidates: [] }, version: 1 },
action: { kind: "confirm", prompt: "Confermi?" },
});
const g = golden("artifact_gate_F5.json");
assert.equal(result.widget, "artifact-gate");
assert.equal(result.artifact.kind, "schema_linking");
assert.equal(result.action.kind, "confirm");
assert.deepEqual(result.reserved, g.reserved);
});
// --- the other widgets --------------------------------------------------------
test("info request is fire-and-forget (no reserved, no options)", () => {
const result = buildInfoRequest({ phase: "F2", level: "info", text: "sto per proporti lo schema-linking" });
assert.equal(result.widget, "info");
assert.equal(result.level, "info");
assert.equal(result.type, "ui_request");
assert.equal(result.id, undefined, "info is not correlated like a blocking widget");
assert.ok(!("reserved" in result));
});
test("freetext request carries a title and no options", () => {
const result = buildFreetextRequest({ id: "u9", phase: "F1", title: "Specifica…" });
assert.equal(result.widget, "freetext");
assert.equal(result.title, "Specifica…");
assert.deepEqual(result.reserved, ["back", "exit", "other"]);
});
test("withChildLinkage sets option.opens and returns the option", () => {
const child = { widget: "freetext", title: "Motivazione del rifiuto" };
const option = withChildLinkage({ id: "reject", label: "Rifiuta" }, child);
assert.deepEqual(option.opens, child);
});
// --- fuzzy tests (bad params must throw clearly, never silently break) --------
test("select with missing title throws clearly", () => {
assert.throws(
() => buildSelectRequest({ id: "u", phase: "F1", options: [] }),
/title/i,
);
});
test("select with non-array options throws clearly", () => {
assert.throws(
() => buildSelectRequest({ id: "u", phase: "F1", title: "x", options: "notalist" }),
/options/i,
);
});
test("artifact-gate with missing artifact throws clearly", () => {
assert.throws(
() => buildArtifactGate({ id: "u", phase: "F5", title: "x", action: { kind: "confirm" } }),
/artifact/i,
);
});
test("artifact-gate with unknown action kind throws clearly", () => {
assert.throws(
() => buildArtifactGate({
id: "u", phase: "F5", title: "x",
artifact: { kind: "cte", data: {}, version: 1 },
action: { kind: "bogus" },
}),
/action/i,
);
});