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:
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"type": "ui_request",
|
||||
"id": "u2",
|
||||
"phase": "F5",
|
||||
"schema_version": 1,
|
||||
"widget": "artifact-gate",
|
||||
"title": "Schema-linking",
|
||||
"artifact": {
|
||||
"kind": "schema_linking",
|
||||
"data": { "candidates": [] },
|
||||
"version": 1
|
||||
},
|
||||
"action": { "kind": "confirm", "prompt": "Confermi?" },
|
||||
"reserved": ["back", "exit", "other"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "ui_request",
|
||||
"id": "u3",
|
||||
"phase": "F4",
|
||||
"schema_version": 1,
|
||||
"widget": "multiselect",
|
||||
"title": "Tabelle",
|
||||
"allow_empty": false,
|
||||
"allow_other": true,
|
||||
"options": [],
|
||||
"selected": [],
|
||||
"content": { "candidates": [] },
|
||||
"reserved": ["back", "exit", "other"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"type": "ui_request",
|
||||
"id": "u1",
|
||||
"phase": "F1",
|
||||
"schema_version": 1,
|
||||
"widget": "select",
|
||||
"title": "Disambigua 'ablazione'",
|
||||
"intro": null,
|
||||
"allow_other": true,
|
||||
"recommended": null,
|
||||
"options": [
|
||||
{ "id": "o1", "label": "procedura" },
|
||||
{ "id": "o2", "label": "patologia" },
|
||||
{ "id": "other", "label": "Altro — specifica…", "opens": { "widget": "freetext", "title": "Specifica…" } }
|
||||
],
|
||||
"reserved": ["back", "exit", "other"]
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// Pure widget-descriptor builders (spec D2/D4, §4.1).
|
||||
//
|
||||
// Each function turns plain params into a ui_request descriptor object. No Pi
|
||||
// context, no I/O -- this is the part of the gate that is fully testable in L1
|
||||
// (in JS, in-language, no Python mirror). The glue (emission via ctx.sendRaw,
|
||||
// anti-bypass, the no-limbo loop) is in nsp-gate.js and is verified at L2.
|
||||
//
|
||||
// The 6 widget kinds: info, select, multiselect, freetext, artifact-gate, artifact.
|
||||
// `widget` is an open field (a new kind needs a renderer, not infra changes);
|
||||
// these are the initial 6. reserved = framework escape hatches always present on
|
||||
// blocking widgets: back / exit / other.
|
||||
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const RESERVED = ["back", "exit", "other"];
|
||||
|
||||
const ALTRO_LABEL = "Altro — specifica…";
|
||||
const VALID_ACTION_KINDS = new Set(["confirm", "approve_reject", "view_only"]);
|
||||
const VALID_INFO_LEVELS = new Set(["info", "warning", "error"]);
|
||||
|
||||
function requireString(value, name, ctx) {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`builders: ${ctx} requires a non-empty ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireArray(value, name, ctx) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`builders: ${ctx} requires ${name} to be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// An "other" option that opens a freetext child widget. Always present on
|
||||
// blocking pick widgets so the reviewer is never trapped in the listed options.
|
||||
function altroOption() {
|
||||
return {
|
||||
id: "other",
|
||||
label: ALTRO_LABEL,
|
||||
opens: { widget: "freetext", title: "Specifica…" },
|
||||
};
|
||||
}
|
||||
|
||||
// Build a `select` ui_request (single-pick). Reserved hatches (Altro/Back/Exit)
|
||||
// are injected; Altro carries the freetext linkage (§4.2). `recommended` is the
|
||||
// id of the option to highlight as "(consigliato)".
|
||||
function buildSelectRequest({
|
||||
id, phase, title, options, intro = null, allowOther = true, recommended = null,
|
||||
}) {
|
||||
requireString(title, "title", "select");
|
||||
const opts = requireArray(options, "options", "select");
|
||||
const out = {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "select",
|
||||
title,
|
||||
intro,
|
||||
allow_other: allowOther,
|
||||
recommended,
|
||||
options: allowOther ? [...opts, altroOption()] : [...opts],
|
||||
reserved: RESERVED,
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build a `multiselect` ui_request (multi-pick). `content` is the scrollable
|
||||
// context (e.g. schema candidates); `selected` the pre-checked option ids;
|
||||
// `allowEmpty:false` with zero options is a broken widget and throws.
|
||||
function buildMultiselectRequest({
|
||||
id, phase, title, options, content = null, selected = [], allowEmpty = false, allowOther = true,
|
||||
}) {
|
||||
requireString(title, "title", "multiselect");
|
||||
const opts = requireArray(options, "options", "multiselect");
|
||||
if (!allowEmpty && opts.length === 0) {
|
||||
throw new Error(
|
||||
"builders: multiselect with allow_empty:false requires at least one option",
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "multiselect",
|
||||
title,
|
||||
allow_empty: allowEmpty,
|
||||
allow_other: allowOther,
|
||||
options: [...opts],
|
||||
selected: [...selected],
|
||||
content,
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Build an `artifact-gate` ui_request: a rendered artifact + a disposition list
|
||||
// (confirm / approve_reject / view_only). The decision is load-bearing on the
|
||||
// document, so the artifact is never shown alone (spec §4.1).
|
||||
function buildArtifactGate({ id, phase, title, artifact, action }) {
|
||||
requireString(title, "title", "artifact-gate");
|
||||
if (!artifact || typeof artifact !== "object") {
|
||||
throw new Error("builders: artifact-gate requires an artifact object");
|
||||
}
|
||||
if (!artifact.kind) {
|
||||
throw new Error("builders: artifact-gate artifact requires a kind");
|
||||
}
|
||||
if (!action || !action.kind || !VALID_ACTION_KINDS.has(action.kind)) {
|
||||
throw new Error(
|
||||
`builders: artifact-gate action.kind must be one of ${[...VALID_ACTION_KINDS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "artifact-gate",
|
||||
title,
|
||||
artifact,
|
||||
action,
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Build an `info` ui_request: fire-and-forget notification (no response expected,
|
||||
// no reserved hatches, no correlated id).
|
||||
function buildInfoRequest({ phase, level, text }) {
|
||||
if (!level || !VALID_INFO_LEVELS.has(level)) {
|
||||
throw new Error(
|
||||
`builders: info level must be one of ${[...VALID_INFO_LEVELS].join(", ")}`,
|
||||
);
|
||||
}
|
||||
requireString(text, "text", "info");
|
||||
return {
|
||||
type: "ui_request",
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "info",
|
||||
level,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a `freetext` ui_request: free-text input, always a child of
|
||||
// select/artifact-gate (via Altro/Rifiuta) or via the ambient steering channel.
|
||||
function buildFreetextRequest({ id, phase, title }) {
|
||||
requireString(title, "title", "freetext");
|
||||
return {
|
||||
type: "ui_request",
|
||||
id,
|
||||
phase,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
widget: "freetext",
|
||||
title,
|
||||
reserved: RESERVED,
|
||||
};
|
||||
}
|
||||
|
||||
// Attach a child-widget spec to an option (linkage, §4.2). Returns the option.
|
||||
function withChildLinkage(option, widgetSpec) {
|
||||
option.opens = widgetSpec;
|
||||
return option;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSelectRequest,
|
||||
buildMultiselectRequest,
|
||||
buildArtifactGate,
|
||||
buildInfoRequest,
|
||||
buildFreetextRequest,
|
||||
withChildLinkage,
|
||||
SCHEMA_VERSION,
|
||||
RESERVED,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "thothii-harness-gate",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Pi gate extension for the ThothII NL->SQL harness (widget-descriptor builders + glue)",
|
||||
"scripts": {
|
||||
"test": "node --test .pi/extensions/gate/__tests__/*.test.js"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user