diff --git a/harness/.pi/extensions/gate/__tests__/builders.test.js b/harness/.pi/extensions/gate/__tests__/builders.test.js new file mode 100644 index 00000000..612fb3ea --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/builders.test.js @@ -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, + ); +}); diff --git a/harness/.pi/extensions/gate/__tests__/golden/artifact_gate_F5.json b/harness/.pi/extensions/gate/__tests__/golden/artifact_gate_F5.json new file mode 100644 index 00000000..32268c5a --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/golden/artifact_gate_F5.json @@ -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"] +} diff --git a/harness/.pi/extensions/gate/__tests__/golden/multiselect_F4.json b/harness/.pi/extensions/gate/__tests__/golden/multiselect_F4.json new file mode 100644 index 00000000..2d3fe773 --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/golden/multiselect_F4.json @@ -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"] +} diff --git a/harness/.pi/extensions/gate/__tests__/golden/select_F1.json b/harness/.pi/extensions/gate/__tests__/golden/select_F1.json new file mode 100644 index 00000000..bf5a40c3 --- /dev/null +++ b/harness/.pi/extensions/gate/__tests__/golden/select_F1.json @@ -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"] +} diff --git a/harness/.pi/extensions/gate/builders.js b/harness/.pi/extensions/gate/builders.js new file mode 100644 index 00000000..7d13eb63 --- /dev/null +++ b/harness/.pi/extensions/gate/builders.js @@ -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, +}; diff --git a/harness/package.json b/harness/package.json new file mode 100644 index 00000000..2d3df32c --- /dev/null +++ b/harness/package.json @@ -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" + } +}