test/capsule-ui-advanced.test.js
Rama main · réplica de lectura del repositorio original.
var test = require("node:test");
var assert = require("node:assert");
var fs = require("fs");
var path = require("path");
var pathToFileURL = require("url").pathToFileURL;
var spec = require("../lib/tool-ui-spec");
var root = path.join(__dirname, "..");
function read(relative) { return fs.readFileSync(path.join(root, relative), "utf8"); }
test("advanced condition AST, else, switch, and dynamic props validate safely", function () {
var tree = {
type: "stack",
children: [
{ type: "callout", when: { all: [{ equals: { path: "status", value: "error" } }, { not: { in: { path: "code", values: [200, 201] } } }] }, props: { title: { $bind: "errorTitle", fallback: "Error" } }, else: { type: "text", props: { text: "Ready" } } },
{ type: "switch", bind: "mode", children: [
{ type: "case", props: { value: "list" }, children: [{ type: "text", props: { text: "List" } }] },
{ type: "case", props: { default: true }, children: [{ type: "text", props: { text: "Other" } }] },
] },
{ type: "button", action: "save", props: { label: { $bind: "saveLabel", fallback: "Save" }, variant: { $bind: "buttonVariant", $enum: ["primary", "secondary", "ghost", "danger"], fallback: "primary" }, disabled: { $bind: "busy", fallback: false } } },
],
};
assert.strictEqual(spec.validateUiNode(tree), true);
assert.throws(function () { spec.validateUiNode({ type: "text", when: { eval: "state.secret" } }); }, /allowed condition operator/);
assert.throws(function () { spec.validateUiNode({ type: "text", when: { gt: { path: "count", value: "many" } } }); }, /finite numeric/);
assert.throws(function () { spec.validateUiNode({ type: "switch", bind: "mode", children: [{ type: "text" }] }); }, /case nodes/);
assert.throws(function () { spec.validateUiNode({ type: "icon", props: { icon: { $bind: "icon" } } }); }, /does not allow a dynamic value/);
assert.throws(function () { spec.validateUiNode({ type: "button", action: "save", props: { label: "Save", variant: { $bind: "variant", fallback: "primary" } } }); }, /allowed enum values/);
assert.throws(function () { spec.validateUiNode({ type: "text", props: { text: { $bind: "__proto__.value" } } }); }, /safe \$bind/);
});
test("advanced forms, options, overlays, collections, and charts enforce bounded contracts", function () {
var tree = { type: "stack", children: [
{ type: "form", action: "submit", props: { label: "Settings", submitLabel: "Save" }, children: [
{ type: "input", id: "age", bind: "age", action: "setAge", props: { label: "Age", inputType: "number", error: { $bind: "errors.age", fallback: "" }, validation: { min: 0, max: 130, step: 1, pattern: "integer" } } },
{ type: "select", id: "role", bind: "role", action: "setRole", props: { label: "Role", options: { $bind: "roleOptions" } } },
] },
{ type: "tabs", bind: "tab", action: "setTab", props: { label: "Views" }, children: [{ type: "tab", props: { label: "Summary", value: "summary" } }] },
{ type: "tabs", bind: "dynamicTab", action: "setDynamicTab", props: { label: "Dynamic views", options: { $bind: "tabOptions", fallback: [] } } },
{ type: "dialog", props: { label: "Confirm deletion", description: "This cannot be undone.", open: { $bind: "confirming", fallback: false }, closeAction: "closeDialog" }, children: [{ type: "button", action: "remove", props: { label: "Delete", variant: "danger" } }] },
{ type: "menu", props: { label: "Record actions", triggerLabel: "Actions" }, children: [{ type: "menu-item", action: "archive", props: { label: "Archive" } }] },
{ type: "menu", action: "chooseAction", props: { label: "Dynamic actions", triggerLabel: "Choose", options: { $bind: "menuOptions", fallback: [] } } },
{ type: "table", bind: "rows", props: { columns: [{ key: "name", label: "Name" }], filter: { $bind: "query" }, filterKey: "name", sortKey: "score", sortDirection: "desc", page: { $bind: "page", fallback: 1 }, pageSize: 25 } },
{ type: "table", bind: "rows", props: { columns: { $bind: "columns", fallback: [{ key: "name", label: "Name" }] } } },
{ type: "pagination", bind: "page", props: { label: "Results pages", total: { $bind: "total", fallback: 0 }, pageSize: 25, pageAction: "setPage" } },
{ type: "chart", bind: "metrics", props: { label: "Weekly activity", kind: "bar", categoryKey: "day", valueKey: "count", maxItems: 50 } },
{ type: "chart", bind: "metrics", props: { label: "Completion", kind: "progress", valueKey: "count", max: { $bind: "goal", fallback: 100 }, maxItems: { $bind: "chartLimit", fallback: 10 } } },
{ type: "chart", bind: "metrics", props: { label: "Total", kind: "metric", valueKey: "count" } },
] };
assert.strictEqual(spec.validateUiNode(tree), true);
assert.throws(function () { spec.validateUiNode({ type: "input", id: "bad", bind: "x", action: "set", props: { label: "X", validation: { pattern: ".*" } } }); }, /pattern is not allowed/);
assert.throws(function () { spec.validateUiNode({ type: "chart", bind: "rows", props: { label: "Too much", kind: "bar", categoryKey: "x", valueKey: "y", maxItems: 51 } }); }, /integer from 1 to 50/);
assert.throws(function () { spec.validateUiNode({ type: "chart", bind: "rows", props: { label: "Progress", kind: "progress", valueKey: "value" } }); }, /requires a positive max/);
assert.throws(function () { spec.validateUiNode({ type: "chart", bind: "rows", props: { label: "Progress", kind: "progress", valueKey: "value", max: -1 } }); }, /positive finite/);
assert.throws(function () { spec.validateUiNode({ type: "chart", bind: "rows", props: { label: "Bars", kind: "bar", valueKey: "value" } }); }, /requires categoryKey/);
assert.strictEqual(spec.validateUiNode({ type: "chart", bind: "rows", props: { label: "Metric", kind: "metric", valueKey: "value" } }), true);
assert.strictEqual(spec.validateUiNode({ type: "list", bind: "rows", props: { page: { $bind: "page", fallback: 1 }, pageSize: { $bind: "pageSize", fallback: 20 } } }), true);
assert.throws(function () { spec.validateUiNode({ type: "switch", bind: "mode", children: [{ type: "case", props: { value: { $bind: "dynamicCase" } } }] }); }, /static scalar/);
assert.throws(function () { spec.validateUiNode({ type: "pagination", bind: "page", props: { pageAction: "setPage", pageSize: 20 } }); }, /requires bind, total, and pageAction/);
assert.throws(function () { spec.validateUiNode({ type: "select", id: "bad-options", bind: "x", action: "set", props: { label: "X", options: [{ value: {}, label: "Bad" }] } }); }, /option value\/label types/);
});
test("chart geometry is bounded, truthful, and resilient to hostile numeric data", async function () {
var chart = await import(pathToFileURL(path.join(root, "lib/public/modules/tool-renderer-chart.js")).href);
var data = chart.chartData({ rows: [{ label: "Low", value: 2 }, { label: "High", value: 10 }, { label: "Bad", value: "Infinity" }, { label: "Negative", value: -8 }] }, "rows", { categoryKey: "label", valueKey: "value", maxItems: 3 }, null);
assert.deepStrictEqual(data, [{ label: "Low", value: 2 }, { label: "High", value: 10 }, { label: "Bad", value: 0 }]);
var line = chart.lineGeometry(data);
assert.deepStrictEqual(line.map(function (point) { return point.y; }), [32.4, 2, 40]);
assert.deepStrictEqual(line.map(function (point) { return point.x; }), [0, 50, 100]);
var donut = chart.donutGeometry([{ label: "A", value: 1 }, { label: "B", value: 3 }]);
assert.strictEqual(donut[0].percent, 25);
assert.strictEqual(donut[1].percent, 75);
assert.strictEqual(donut[1].offset, 25);
assert.strictEqual(chart.donutGeometry(new Array(30).fill({ label: "x", value: 1 })).length, 12);
assert.match(read("lib/public/modules/tool-renderer-chart.js"), /props\.kind === "donut"\) data = data\.slice\(0, 12\)/);
});
function MiniElement(tag) {
this.tagName = tag;
this.children = [];
this.attributes = {};
this.dataset = {};
this.className = "";
this.textContent = "";
this.listeners = {};
}
MiniElement.prototype.appendChild = function (child) { this.children.push(child); return child; };
MiniElement.prototype.setAttribute = function (name, value) { this.attributes[name] = String(value); };
MiniElement.prototype.addEventListener = function (name, listener) { this.listeners[name] = listener; };
MiniElement.prototype.contains = function (target) { return this === target || this.children.some(function (child) { return child.contains && child.contains(target); }); };
MiniElement.prototype.querySelectorAll = function () { return []; };
MiniElement.prototype.focus = function () {};
test("progress uses declared maximum and advanced groups allocate unique deterministic IDs", async function () {
var originalDocument = global.document;
var fakeDocument = {
activeElement: null,
createElement: function (tag) { return new MiniElement(tag); },
createElementNS: function (namespace, tag) { return new MiniElement(tag); },
addEventListener: function () {},
removeEventListener: function () {},
};
global.document = fakeDocument;
try {
var chart = await import(pathToFileURL(path.join(root, "lib/public/modules/tool-renderer-chart.js")).href);
var advanced = await import(pathToFileURL(path.join(root, "lib/public/modules/tool-renderer-advanced.js")).href);
var context = { toolId: "demo", state: { rows: [{ value: 25 }] }, item: null, emit: function () {}, container: new MiniElement("div"), disposers: [], autoControlIndex: 1 };
var progressFigure = chart.renderChartNode({ type: "chart", bind: "rows" }, { label: "Progress", kind: "progress", valueKey: "value", max: 100, maxItems: 1 }, context);
var progress = progressFigure.children.filter(function (child) { return child.tagName === "progress"; })[0];
assert.strictEqual(progress.max, 100);
assert.strictEqual(progress.value, 25);
var tabs = { type: "tabs", bind: "tab", action: "setTab", props: { label: "Views" }, children: [{ type: "tab", props: { label: "One", value: "one" } }] };
var firstTabs = advanced.renderAdvancedNode(tabs, tabs.props, context, function () { return new MiniElement("span"); });
var secondTabs = advanced.renderAdvancedNode(tabs, tabs.props, context, function () { return new MiniElement("span"); });
var firstId = firstTabs.children[0].children[0].id;
var secondId = secondTabs.children[0].children[0].id;
assert.notStrictEqual(firstId, secondId);
assert.match(firstId, /tabs-1/);
assert.match(secondId, /tabs-2/);
var dialog = { type: "dialog", props: { label: "Details", description: "Safe", open: true, closeAction: "close" }, children: [] };
var firstDialog = advanced.renderAdvancedNode(dialog, dialog.props, context, function () { return new MiniElement("span"); });
var secondDialog = advanced.renderAdvancedNode(dialog, dialog.props, context, function () { return new MiniElement("span"); });
assert.notStrictEqual(firstDialog.children[0].children[0].id, secondDialog.children[0].children[0].id);
for (var di = 0; di < context.disposers.length; di++) context.disposers[di]();
} finally {
global.document = originalDocument;
}
});
test("disposed closed dialogs cannot steal focus through a delayed return", async function () {
var originalDocument = global.document;
var focusCount = 0;
var fakeDocument = { activeElement: null, createElement: function (tag) { return new MiniElement(tag); }, addEventListener: function () {}, removeEventListener: function () {} };
global.document = fakeDocument;
try {
var advanced = await import(pathToFileURL(path.join(root, "lib/public/modules/tool-renderer-advanced.js")).href);
var container = new MiniElement("div");
container.querySelectorAll = function () { return [{ dataset: { toolControlId: "opener" }, focus: function () { focusCount++; } }]; };
var openContext = { toolId: "focus", state: {}, item: null, emit: function () {}, container: container, disposers: [], autoControlIndex: 1, activeControlId: "opener" };
advanced.renderAdvancedNode({ type: "dialog", props: {} }, { label: "Dialog", open: true, closeAction: "close" }, openContext, function () { return new MiniElement("span"); });
for (var oi = 0; oi < openContext.disposers.length; oi++) openContext.disposers[oi]();
var closedContext = { toolId: "focus", state: {}, item: null, emit: function () {}, container: container, disposers: [], autoControlIndex: 1 };
advanced.renderAdvancedNode({ type: "dialog", props: {} }, { label: "Dialog", open: false, closeAction: "close" }, closedContext, function () { return new MiniElement("span"); });
for (var ci = 0; ci < closedContext.disposers.length; ci++) closedContext.disposers[ci]();
await new Promise(function (resolve) { setTimeout(resolve, 2); });
assert.strictEqual(focusCount, 0);
} finally {
global.document = originalDocument;
}
});
test("pure renderer evaluator executes conditions and bounded filter sort pagination", async function () {
var evaluator = await import(pathToFileURL(path.join(root, "lib/public/modules/tool-ui-evaluator.js")).href);
var state = { ready: true, status: "active", count: 7, query: "a", page: 2, variant: "primary" };
assert.strictEqual(evaluator.evaluateCondition({ all: [{ equals: { path: "status", value: "active" } }, { gte: { path: "count", value: 7 } }] }, state), true);
assert.strictEqual(evaluator.evaluateCondition({ any: [{ notEquals: { path: "status", value: "active" } }, { in: { path: "count", values: [6, 7] } }] }, state), true);
assert.strictEqual(evaluator.evaluateCondition({ not: "ready" }, state), false);
assert.deepStrictEqual(evaluator.resolveProps({ variant: { $bind: "variant", $enum: ["primary", "ghost"], fallback: "ghost" }, missing: { $bind: "missing", fallback: "Fallback" } }, state), { variant: "primary", missing: "Fallback" });
var items = [];
for (var i = 0; i < 650; i++) items.push({ name: i % 2 ? "alpha" + i : "beta" + i, score: i });
var view = evaluator.collectionView(items, { filter: "alpha", filterKey: "name", sortKey: "score", sortDirection: "desc", page: 2, pageSize: 10 });
assert.strictEqual(view.length, 10);
assert.strictEqual(view[0].score, 479);
assert.ok(view.every(function (item) { return item.name.indexOf("alpha") !== -1; }));
var options = evaluator.normalizeOptions([{ value: "safe", label: "Safe" }, { value: {}, label: "Unsafe" }].concat(new Array(120).fill("x")));
assert.strictEqual(options.length, 99);
var columns = evaluator.normalizeColumns([{ key: "name", label: "Name" }, { key: "__proto__.secret", label: "No" }]);
assert.deepStrictEqual(columns, [{ key: "name", label: "Name" }]);
});
test("advanced renderer remains host-owned, text-safe, accessible, and free of authored escape hatches", function () {
var advanced = read("lib/public/modules/tool-renderer-advanced.js");
var renderer = read("lib/public/modules/tool-renderer.js");
var chartRenderer = read("lib/public/modules/tool-renderer-chart.js");
var semantics = read("lib/public/modules/tool-renderer-semantics.js");
var css = read("lib/public/css/capsule-ui.css");
assert.match(advanced, /role", "dialog"/);
assert.match(advanced, /aria-modal/);
assert.match(advanced, /role", "tablist"/);
assert.match(advanced, /aria-selected/);
assert.match(advanced, /role", "menu"/);
assert.match(advanced, /event\.key === "Escape"/);
assert.match(advanced, /event\.key !== "Tab"/);
assert.match(advanced, /dialogOpeners/);
assert.match(advanced, /returnTimer[\s\S]*context\.disposers\.push/);
assert.match(advanced, /closeMenu\(true\)[\s\S]*emit\(selected/);
assert.match(renderer, /action-" \+ node\.action/);
assert.match(renderer, /props\.error/);
assert.match(renderer, /Math\.max\(2, Math\.min\(16/);
assert.match(renderer, /aria-invalid/);
assert.match(renderer, /normalizeOptions/);
assert.match(read("lib/public/modules/tool-ui-evaluator.js"), /slice\(0, 100\)/);
assert.match(semantics, /TOKEN_VALUES/);
assert.doesNotMatch(renderer + advanced + chartRenderer, /insertAdjacentHTML|outerHTML|props\.(?:class|style|html)|createElement\(["']script/);
assert.match(chartRenderer, /createElementNS/);
assert.match(chartRenderer, /tool-chart-data/);
assert.match(css, /\.tool-dialog-overlay/);
assert.match(css, /\.tool-chart/);
assert.match(css, /@media \(max-width: 600px\)/);
});
test("authoring contract describes the advanced safe grammar without executable markup", function () {
var description = spec.authoringDescription();
assert.match(description, /all\/any\/not\/equals\/notEquals\/in\/gt\/gte\/lt\/lte/);
assert.match(description, /Forms provide bounded field validation/);
assert.match(description, /Tabs and menus/);
assert.match(description, /bar\/line\/donut\/progress\/metric/);
assert.match(description, /at most 50 points/);
assert.match(description, /Arbitrary class, style, HTML, JavaScript, authored SVG/);
});