From ad04ff6f56b1ddddc25b44fac8db90351d3b6326 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 15 Oct 2026 21:26:07 -0500 Subject: [PATCH] Move static markdown, code and stepper construction to TypeScript - Compile fixed-order construction programs for static markdown, code and stepper builders. - Preserve source/active precedence, first-occurrence channels, lexical slot scope, borrowed storage and partial failure effects. - Add full-builder differential coverage for callbacks, allocation and resize failures, and finalized trees. --- build.zig | 1 + packages/core/src/markup_specialized.ts | 20 +- packages/core/test/markup_specialized.test.ts | 38 +++ src/primitives/canvas/ui_markup_compiled.zig | 307 +++++++++-------- .../static_markup_content_e2e_tests.zig | 314 ++++++++++++++++++ tests/ts-core/surface_layout_e2e_tests.zig | 1 + 6 files changed, 550 insertions(+), 131 deletions(-) create mode 100644 tests/ts-core/static_markup_content_e2e_tests.zig diff --git a/build.zig b/build.zig index 938eeab82..a860ea831 100644 --- a/build.zig +++ b/build.zig @@ -1585,6 +1585,7 @@ pub fn build(b: *std.Build) void { b.step("test-ts-static-markup-specialized", "Compare static specialized metadata, forwarded handlers, tab triggers and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-specialized-tests", &.{ "static markup", "compiled markup values", "compiled markup metadata", "compiled markup options", "compiled markup postprocessing", "compiled markup numeric", "compiled markup admission", "compiled markup literals" })).step); b.step("test-ts-static-markup-typography", "Compare static spans, interpolation, callback order and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-typography-tests", &.{ "static markup", "compiled markup specialized", "compiled markup runtime" })).step); b.step("test-ts-static-markup-builders", "Compare static forms, timelines, charts, video and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-builder-tests", &.{ "static markup builders", "compiled markup specialized" })).step); + b.step("test-ts-static-markup-content", "Compare static markdown, code and stepper construction and complete failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-static-markup-content-tests", &.{ "static markup content builders", "compiled markup specialized" })).step); b.step("test-ts-markup-postprocessing", "Compare forwarded handlers, provenance, tabs and borrowed binding paths").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-postprocessing-tests", &.{ "compiled markup postprocessing", "compiled markup literals", "compiled markup admission", "compiled markup construction" })).step); b.step("test-ts-markup-literals", "Compare exact literal inference, template defaults and lexical capture ownership").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-literal-tests", &.{ "compiled markup literals", "compiled markup admission", "compiled markup construction" })).step); b.step("test-ts-markup-admission", "Compare specialized attribute forms, raw flags and complete eager failure effects").dependOn(&b.addRunArtifact(filteredTestArtifact(b, ts_core_artifacts.surface_layout.root_module, "ts-markup-admission-tests", &.{ "compiled markup admission", "compiled markup specialized", "compiled markup options", "compiled markup metadata" })).step); diff --git a/packages/core/src/markup_specialized.ts b/packages/core/src/markup_specialized.ts index 1584ffbfb..52f98d4e4 100644 --- a/packages/core/src/markup_specialized.ts +++ b/packages/core/src/markup_specialized.ts @@ -60,6 +60,24 @@ function nscsNode(r: NscElementReader, out: number[], mode: number): void { nscsStaticAttrs(node, out, ["src", "controls", "autoplay", "loop", "muted", "width", "height", "grow", "label", "key", "global-key"]); nsceEmit(out, 200); return; } + if (mode === 29) { + nscsStaticAttrs(node, out, ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"]); + nsceEmit(out, 200); return; + } + if (mode === 30) { + nscsStaticAttrs(node, out, ["source", "language", "line-numbers", "added-lines", "removed-lines", "editable", "on-input", "wrap", "width", "height", "min-width", "grow", "label", "key", "global-key"]); + nsceEmit(out, 200); return; + } + if (mode === 31) { + // Inherited slot steps interpolate in their lexical site scope; ordinary + // steps use the stepper's scope. The step array precedes every label. + const steps = r.word(), slot = r.flag(); + if (slot ? node.children.length !== 1 : steps !== node.children.length) throw new Error("invalid static stepper facts"); + nscsStaticAttrs(node, out, ["active", "key", "global-key", "label"]); + nsceEmit(out, 100); + for (let i = 0; i < steps; i++) nsceEmit(out, slot ? 102 : 101, 0, i); + nsceEmit(out, 200); return; + } if (mode === 2) { if (node.children.length !== 0) { nsceFail(out, 1, 1); return; } if (!nscsAttrs(node, out, ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"], 2, true)) return; @@ -192,7 +210,7 @@ function nscsNode(r: NscElementReader, out: number[], mode: number): void { throw new Error("invalid specialized node mode"); } function nscvMarkupSpecialized(input: Uint8Array): Uint8Array { - if (input.length < 4 || input[0] !== 112 || input[1] !== 1 || input[2]! > 28 || input[3]! > 1) throw new Error("invalid specialized header"); + if (input.length < 4 || input[0] !== 112 || input[1] !== 1 || input[2]! > 31 || input[3]! > 1) throw new Error("invalid specialized header"); const r = new NscElementReader(input), out: number[] = [], mode = input[2]!; if (mode === 15) { const count = r.word(), expected = r.word(); diff --git a/packages/core/test/markup_specialized.test.ts b/packages/core/test/markup_specialized.test.ts index 6569ca8a8..1e3bf4920 100644 --- a/packages/core/test/markup_specialized.test.ts +++ b/packages/core/test/markup_specialized.test.ts @@ -141,3 +141,41 @@ test("runtime specialized builders retain source order and repeated attributes", [11, "series", ["label", "values", "label"], [], [4, 2, 4, 200]], ] as const) assert.deepEqual(actions(new Packet(mode).node(name, attrs(...names), [...children])), expected); }); + +test("static markdown code and stepper programs fix source and active before first-occurrence channels", () => { + const specs = [ + [29, "markdown", ["source", "on-link", "on-details", "details-expanded", "images", "issue-link-base"], [], [200]], + [30, "code", ["source", "language", "line-numbers", "added-lines", "removed-lines", "editable", "on-input", "wrap", "width", "height", "min-width", "grow", "label", "key", "global-key"], [], [200]], + [31, "stepper", ["active", "key", "global-key", "label"], [{ name: "step" }, { name: "step" }], [100, 101, 101, 200]], + ] as const; + for (const [mode, name, names, children, tail] of specs) { + const facts = mode === 31 ? [children.length, 0] : []; + const all = (1 << names.length) - 1; + const masks = [1, all, (all & 0x5555) | 1, all & 0x2aaa | 1, ...names.flatMap((_, i) => [1 | (1 << i), all ^ (i === 0 ? 0 : 1 << i)])]; + for (const mask of masks) { + const selected = names.filter((_, i) => (mask & (1 << i)) !== 0).reverse(); + const rows = new Packet(mode).node(name, attrs("kind", ...selected, ...selected), [...children]).word(...facts).actions(); + const channels = names.flatMap((_, i) => (mask & (1 << i)) !== 0 ? [i + 1] : []); + assert.deepEqual(rows.map(row => row.action), [...channels, ...tail]); + for (const row of rows.slice(0, channels.length)) assert.equal(row.fields[0], selected.indexOf(names[row.action - 1]!) + 1); + if (mode === 31) assert.deepEqual(rows.filter(row => row.action === 101).map(row => row.fields[0]), [0, 1]); + } + const bytes = new Packet(mode).node(name, attrs(...names), [...children]).word(...facts).bytes(); + for (let end = 0; end < bytes.length; end++) assert.throws(() => run(bytes.subarray(0, end))); + assert.throws(() => run(Uint8Array.of(...bytes, 0))); + const sizing = bytes.slice(); sizing[3] = 0; + assert.equal(new DataView(run(sizing).buffer).getFloat64(8, true), names.length + tail.length); + } + const slot = new Packet(31).node("stepper", attrs("label", "active"), [{ kind: 6 }]).word(3, 1).actions(); + assert.deepEqual(slot.map(row => [row.action, row.fields[0]]), [[1, 1], [4, 0], [100, 0], [102, 0], [102, 1], [102, 2], [200, 0]]); + assert.deepEqual(actions(new Packet(31).node("stepper", attrs("active"), [{ kind: 6 }]).word(0, 1)), [1, 100, 200]); + assert.deepEqual(actions(new Packet(31).node("stepper", attrs("active")).word(0, 0)), [1, 100, 200]); + for (const facts of [[1, 0], [0, 1], [2, 1], [0, 2]]) assert.throws(() => run(new Packet(31).node("stepper", attrs("active")).word(...facts).bytes())); + assert.throws(() => run(new Packet(32).node("markdown", attrs("source")).bytes())); +}); + +test("runtime markdown code and stepper programs retain source order and repeated attributes", () => { + assert.deepEqual(actions(new Packet(2).node("markdown", attrs("images", "source", "images"))), [5, 1, 5, 200]); + assert.deepEqual(actions(new Packet(3).node("code", attrs("label", "source", "label"))), [15, 1, 15, 200]); + assert.deepEqual(actions(new Packet(4).node("stepper", attrs("label", "active", "label"))), [4, 1, 4, 100]); +}); diff --git a/src/primitives/canvas/ui_markup_compiled.zig b/src/primitives/canvas/ui_markup_compiled.zig index f94fd819f..ac0c47995 100644 --- a/src/primitives/canvas/ui_markup_compiled.zig +++ b/src/primitives/canvas/ui_markup_compiled.zig @@ -1222,43 +1222,54 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res break :blk expression.binding; }; comptime requireVariant(pathVariant(node, entries, source_path, true), &.{.string}, node, markup.markdown_source_message); - const source_text = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) { - .string => |text| text, - else => runtimeFail([]const u8, ui), - }; - + var source_text: []const u8 = undefined; var options: Md.Options = .{}; - if (comptime (node.attr("on-link") != null)) { - options.on_link = comptime markdownLinkConstructor(node, node.attr("on-link").?); - } - if (comptime (node.attr("on-details") != null)) { - options.on_details = comptime markdownDetailsConstructor(node, node.attr("on-details").?); - } - if (comptime (node.attr("details-expanded") != null)) { - options.details_expanded = detailsExpandedItems(node, entries, comptime node.attr("details-expanded").?, ui, model, scope); - } - if (comptime (node.attr("images") != null)) { - options.images = markdownImageItems(node, entries, comptime node.attr("images").?, ui, model, scope); - } - if (comptime (node.attr("issue-link-base") != null)) { - const raw = comptime node.attr("issue-link-base").?; - comptime { - const expression = markup.parseAttrExpression(raw) orelse fail(node, markup.markdown_issue_link_base_message); - if (expression == .equals) fail(node, markup.markdown_issue_link_base_message); - } - comptime requireVariant(attrExprVariant(node, entries, "issue-link-base", raw), &.{.string}, node, markup.markdown_issue_link_base_message); - const base = switch (attrExprValue(node, entries, "issue-link-base", raw, ui, model, scope)) { + var request = specialized.Request.init(29); + defer request.deinit(); + request.node(node, true); + var program = request.program(owner) catch { + return runtimeFail(Ui.Node, ui); + }; + defer program.deinit(); + while (program.next()) |step| switch (step.action) { + 1 => source_text = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) { .string => |text| text, else => runtimeFail([]const u8, ui), - }; - const admission = value_programs.specializedValue(owner, 2, .{ .string = base }); - switch (admission.action) { - 0 => {}, - 1 => options.issue_link_base = base, - else => @panic("invalid static issue link publication capability"), - } - } - return Md.view(ui, source_text, options); + }, + 2 => if (comptime (node.attr("on-link") != null)) { + options.on_link = comptime markdownLinkConstructor(node, node.attr("on-link").?); + } else @panic("unavailable static builder channel"), + 3 => if (comptime (node.attr("on-details") != null)) { + options.on_details = comptime markdownDetailsConstructor(node, node.attr("on-details").?); + } else @panic("unavailable static builder channel"), + 4 => if (comptime (node.attr("details-expanded") != null)) { + options.details_expanded = detailsExpandedItems(node, entries, comptime node.attr("details-expanded").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 5 => if (comptime (node.attr("images") != null)) { + options.images = markdownImageItems(node, entries, comptime node.attr("images").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 6 => if (comptime (node.attr("issue-link-base") != null)) { + const raw = comptime node.attr("issue-link-base").?; + comptime { + const expression = markup.parseAttrExpression(raw) orelse fail(node, markup.markdown_issue_link_base_message); + if (expression == .equals) fail(node, markup.markdown_issue_link_base_message); + } + comptime requireVariant(attrExprVariant(node, entries, "issue-link-base", raw), &.{.string}, node, markup.markdown_issue_link_base_message); + const base = switch (attrExprValue(node, entries, "issue-link-base", raw, ui, model, scope)) { + .string => |text| text, + else => runtimeFail([]const u8, ui), + }; + const admission = value_programs.specializedValue(owner, 2, .{ .string = base }); + switch (admission.action) { + 0 => {}, + 1 => options.issue_link_base = base, + else => @panic("invalid static issue link publication capability"), + } + } else @panic("unavailable static builder channel"), + 200 => return Md.view(ui, source_text, options), + else => @panic("invalid static builder capability"), + }; + @panic("static builder program did not publish"); } fn buildMarkdownReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node { @@ -1353,74 +1364,85 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res break :blk expression.binding; }; comptime requireVariant(pathVariant(node, entries, source_path, true), &.{.string}, node, markup.code_source_message); - const source = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) { - .string => |text| text, - else => runtimeFail([]const u8, ui), - }; - + var source: []const u8 = undefined; var options: Ui.CodeOptions = .{}; var added_lines_storage: [canvas.code.max_diff_lines]usize = undefined; var removed_lines_storage: [canvas.code.max_diff_lines]usize = undefined; - if (comptime (node.attr("language") != null)) { - const name = comptime blk: { - const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message); - if (expression != .literal) fail(node, markup.code_language_message); - if (!canvas.code.isLanguageName(expression.literal)) fail(node, markup.code_language_message); - break :blk expression.literal; - }; - options.language = canvas.code.languageFromName(name); - } - if (comptime (node.attr("line-numbers") != null)) { - options.line_numbers = videoFlagValue(node, entries, comptime node.attr("line-numbers").?, ui, model, scope); - } - if (comptime (node.attr("added-lines") != null)) { - const spec = codeLineSpecAttr(node, entries, comptime node.attr("added-lines").?, ui, model, scope); - options.added_lines = value_programs.lineNumbers(owner, spec, &added_lines_storage) orelse blk: { - ui.failed = true; - break :blk added_lines_storage[0..0]; - }; - } - if (comptime (node.attr("removed-lines") != null)) { - const spec = codeLineSpecAttr(node, entries, comptime node.attr("removed-lines").?, ui, model, scope); - options.removed_lines = value_programs.lineNumbers(owner, spec, &removed_lines_storage) orelse blk: { - ui.failed = true; - break :blk removed_lines_storage[0..0]; - }; - } - if (comptime (node.attr("editable") != null)) { - options.editable = videoFlagValue(node, entries, comptime node.attr("editable").?, ui, model, scope); - } - if (comptime (node.attr("on-input") != null)) { - const expression = comptime (markup.parseMessageExpression(node.attr("on-input").?) orelse - fail(node, "invalid message expression: on-* takes a Msg tag (\"add\") or tag with one binding payload (\"toggle:{item.id}\")")); - options.on_input = comptime (inputConstructor(expression.tag) orelse - fail(node, "on-input tag must carry a TextInputEvent payload")); - } - if (comptime (node.attr("wrap") != null)) { - options.wrap = videoFlagValue(node, entries, comptime node.attr("wrap").?, ui, model, scope); - } - if (comptime (node.attr("width") != null)) { - options.width = floatAttr(node, entries, comptime node.attr("width").?, ui, model, scope); - } - if (comptime (node.attr("height") != null)) { - options.height = floatAttr(node, entries, comptime node.attr("height").?, ui, model, scope); - } - if (comptime (node.attr("min-width") != null)) { - options.min_width = floatAttr(node, entries, comptime node.attr("min-width").?, ui, model, scope); - } - if (comptime (node.attr("grow") != null)) { - options.grow = floatAttr(node, entries, comptime node.attr("grow").?, ui, model, scope); - } - if (comptime (node.attr("label") != null)) { - options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text"); - } - if (comptime (node.attr("key") != null)) { - options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings"); - } - if (comptime (node.attr("global-key") != null)) { - options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings"); - } - return ui.code(options, source); + var request = specialized.Request.init(30); + defer request.deinit(); + request.node(node, true); + var program = request.program(owner) catch { + return runtimeFail(Ui.Node, ui); + }; + defer program.deinit(); + while (program.next()) |step| switch (step.action) { + 1 => source = switch (bindingValue(node, entries, source_path, ui, model, scope, true)) { + .string => |text| text, + else => runtimeFail([]const u8, ui), + }, + 2 => if (comptime (node.attr("language") != null)) { + const name = comptime blk: { + const expression = markup.parseAttrExpression(node.attr("language").?) orelse fail(node, markup.code_language_message); + if (expression != .literal) fail(node, markup.code_language_message); + if (!canvas.code.isLanguageName(expression.literal)) fail(node, markup.code_language_message); + break :blk expression.literal; + }; + options.language = canvas.code.languageFromName(name); + } else @panic("unavailable static builder channel"), + 3 => if (comptime (node.attr("line-numbers") != null)) { + options.line_numbers = videoFlagValue(node, entries, comptime node.attr("line-numbers").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 4 => if (comptime (node.attr("added-lines") != null)) { + const spec = codeLineSpecAttr(node, entries, comptime node.attr("added-lines").?, ui, model, scope); + options.added_lines = value_programs.lineNumbers(owner, spec, &added_lines_storage) orelse blk: { + ui.failed = true; + break :blk added_lines_storage[0..0]; + }; + } else @panic("unavailable static builder channel"), + 5 => if (comptime (node.attr("removed-lines") != null)) { + const spec = codeLineSpecAttr(node, entries, comptime node.attr("removed-lines").?, ui, model, scope); + options.removed_lines = value_programs.lineNumbers(owner, spec, &removed_lines_storage) orelse blk: { + ui.failed = true; + break :blk removed_lines_storage[0..0]; + }; + } else @panic("unavailable static builder channel"), + 6 => if (comptime (node.attr("editable") != null)) { + options.editable = videoFlagValue(node, entries, comptime node.attr("editable").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 7 => if (comptime (node.attr("on-input") != null)) { + const expression = comptime (markup.parseMessageExpression(node.attr("on-input").?) orelse + fail(node, "invalid message expression: on-* takes a Msg tag (\"add\") or tag with one binding payload (\"toggle:{item.id}\")")); + options.on_input = comptime (inputConstructor(expression.tag) orelse + fail(node, "on-input tag must carry a TextInputEvent payload")); + } else @panic("unavailable static builder channel"), + 8 => if (comptime (node.attr("wrap") != null)) { + options.wrap = videoFlagValue(node, entries, comptime node.attr("wrap").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 9 => if (comptime (node.attr("width") != null)) { + options.width = floatAttr(node, entries, comptime node.attr("width").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 10 => if (comptime (node.attr("height") != null)) { + options.height = floatAttr(node, entries, comptime node.attr("height").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 11 => if (comptime (node.attr("min-width") != null)) { + options.min_width = floatAttr(node, entries, comptime node.attr("min-width").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 12 => if (comptime (node.attr("grow") != null)) { + options.grow = floatAttr(node, entries, comptime node.attr("grow").?, ui, model, scope); + } else @panic("unavailable static builder channel"), + 13 => if (comptime (node.attr("label") != null)) { + options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text"); + } else @panic("unavailable static builder channel"), + 14 => if (comptime (node.attr("key") != null)) { + options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings"); + } else @panic("unavailable static builder channel"), + 15 => if (comptime (node.attr("global-key") != null)) { + options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings"); + } else @panic("unavailable static builder channel"), + 200 => return ui.code(options, source), + else => @panic("invalid static builder capability"), + }; + @panic("static builder program did not publish"); } fn buildCodeReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node { @@ -1653,47 +1675,72 @@ fn CompiledMarkupEngine(comptime ModelT: type, comptime MsgT: type, comptime res } } } - var options: Ui.StepperOptions = .{}; const active_raw = comptime node.attr("active").?; comptime requireVariant(exprVariant(node, entries, active_raw), &.{.integer}, node, markup.stepper_active_message); - options.active = runtime_values.collectionIndex(owner, evalExpr(node, entries, active_raw, ui, model, scope), true) orelse runtimeFail(usize, ui); - if (comptime (node.attr("key") != null)) { - options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings"); - } - if (comptime (node.attr("global-key") != null)) { - options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings"); - } - if (comptime (node.attr("label") != null)) { - options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text"); - } const slot_index: ?usize = comptime if (node.children.len == 1 and node.children[0].kind == .slot_block) innermostSlotIndex(entries) else null; if (comptime (node.children.len == 1 and node.children[0].kind == .slot_block and slot_index == null)) { comptime fail(node.children[0], markup.slot_outside_template_message); } const step_nodes = comptime if (slot_index) |index| entries[index].slot_nodes else node.children; - const steps = ui.arena.alloc(Ui.StepperStep, step_nodes.len) catch { - ui.failed = true; - return ui.el(.row, .{}, .{}); + var options: Ui.StepperOptions = .{}; + var steps: []Ui.StepperStep = &.{}; + var request = specialized.Request.init(31); + defer request.deinit(); + request.node(node, true); + request.word(step_nodes.len); + request.word(@intFromBool(slot_index != null)); + var program = request.program(owner) catch { + return runtimeFail(Ui.Node, ui); }; - if (comptime (slot_index != null)) { - const index = comptime slot_index.?; - const site_scope = scopePayload(entries, index, scope); - inline for (0..step_nodes.len) |step_index| { - const child = comptime step_nodes[step_index]; - if (comptime (child.kind != .element or !std.mem.eql(u8, child.name, "step"))) fail(child, markup.stepper_children_message); - comptime { - for (child.attrs) |attribute| { - if (!std.mem.eql(u8, attribute.name, "kind")) fail(child, markup.step_attr_message); + defer program.deinit(); + while (program.next()) |step| switch (step.action) { + 1 => options.active = runtime_values.collectionIndex(owner, evalExpr(node, entries, active_raw, ui, model, scope), true) orelse runtimeFail(usize, ui), + 2 => if (comptime (node.attr("key") != null)) { + options.key = attrKey(node, entries, comptime node.attr("key").?, ui, model, scope, "keys must be integers or strings"); + } else @panic("unavailable static builder channel"), + 3 => if (comptime (node.attr("global-key") != null)) { + options.global_key = attrKey(node, entries, comptime node.attr("global-key").?, ui, model, scope, "keys must be integers or strings"); + } else @panic("unavailable static builder channel"), + 4 => if (comptime (node.attr("label") != null)) { + options.semantics.label = stringAttr(node, entries, "label", comptime node.attr("label").?, ui, model, scope, "label expects text"); + } else @panic("unavailable static builder channel"), + 100 => steps = ui.arena.alloc(Ui.StepperStep, step_nodes.len) catch { + ui.failed = true; + return ui.el(.row, .{}, .{}); + }, + 101 => if (comptime (slot_index == null)) { + var matched = false; + inline for (0..step_nodes.len) |index| { + if (step.fields[0] == index) { + matched = true; + steps[index] = .{ .label = interpolatedText(comptime step_nodes[index], entries, ui, model, scope) }; } } - steps[step_index] = .{ .label = interpolatedText(child, comptime entries[index].slot_entries, ui, model, site_scope) }; - } - } else { - inline for (0..step_nodes.len) |index| { - steps[index] = .{ .label = interpolatedText(comptime step_nodes[index], entries, ui, model, scope) }; - } - } - return ui.stepper(options, steps); + if (!matched) @panic("invalid static step index"); + } else @panic("unavailable static step scope"), + 102 => if (comptime (slot_index != null)) { + const index = comptime slot_index.?; + const site_scope = scopePayload(entries, index, scope); + var matched = false; + inline for (0..step_nodes.len) |step_index| { + const child = comptime step_nodes[step_index]; + if (comptime (child.kind != .element or !std.mem.eql(u8, child.name, "step"))) fail(child, markup.stepper_children_message); + comptime { + for (child.attrs) |attribute| { + if (!std.mem.eql(u8, attribute.name, "kind")) fail(child, markup.step_attr_message); + } + } + if (step.fields[0] == step_index) { + matched = true; + steps[step_index] = .{ .label = interpolatedText(child, comptime entries[index].slot_entries, ui, model, site_scope) }; + } + } + if (!matched) @panic("invalid static step index"); + } else @panic("unavailable static step scope"), + 200 => return ui.stepper(options, steps), + else => @panic("invalid static builder capability"), + }; + @panic("static builder program did not publish"); } fn buildStepperReference(comptime node: markup.MarkupNode, comptime entries: []const ScopeEntry, ui: *Ui, model: *const ModelT, scope: anytype) Ui.Node { diff --git a/tests/ts-core/static_markup_content_e2e_tests.zig b/tests/ts-core/static_markup_content_e2e_tests.zig new file mode 100644 index 000000000..76915bc27 --- /dev/null +++ b/tests/ts-core/static_markup_content_e2e_tests.zig @@ -0,0 +1,314 @@ +const std = @import("std"); +const sdk = @import("native_sdk"); +const core = @import("surface_fixture_core"); +const c = sdk.canvas; +fn exact(a: anytype, b: @TypeOf(a)) anyerror!void { + const T = @TypeOf(a); + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.fields) |field| try exact(@field(a, field.name), @field(b, field.name)), + .optional => { + try std.testing.expectEqual(a != null, b != null); + if (a) |value| try exact(value, b.?); + }, + .pointer => |info| if (info.size == .slice) { + try std.testing.expectEqual(a.len, b.len); + for (a, b) |l, r| try exact(l, r); + } else if (info.size == .one and (@typeInfo(info.child) == .@"struct" or @typeInfo(info.child) == .@"union" or info.child == bool)) try exact(a.*, b.*) else try std.testing.expectEqual(a, b), + .array => for (a, b) |l, r| try exact(l, r), + .@"union" => { + try std.testing.expectEqual(std.meta.activeTag(a), std.meta.activeTag(b)); + switch (a) { + inline else => |value, tag| try exact(value, @field(b, @tagName(tag))), + } + }, + else => try @import("surface_layout_e2e_tests.zig").expectComplete(a, b), + } +} +/// Counts text slices that alias the model's storage, so borrowed and owned +/// buffers stay distinguishable even when their bytes are equal. +fn borrowed(value: anytype, base: []const u8) usize { + const T = @TypeOf(value); + return switch (@typeInfo(T)) { + .@"struct" => |info| blk: { + var total: usize = 0; + inline for (info.fields) |field| total += borrowed(@field(value, field.name), base); + break :blk total; + }, + .optional => if (value) |inner| borrowed(inner, base) else 0, + .@"union" => switch (value) { + inline else => |inner| borrowed(inner, base), + }, + .pointer => |info| if (info.size != .slice) 0 else if (info.child == u8) @intFromBool(value.len > 0 and @intFromPtr(value.ptr) >= @intFromPtr(base.ptr) and @intFromPtr(value.ptr) < @intFromPtr(base.ptr) + base.len) else blk: { + var total: usize = 0; + for (value) |item| total += borrowed(item, base); + break :blk total; + }, + .array => blk: { + var total: usize = 0; + for (value) |item| total += borrowed(item, base); + break :blk total; + }, + else => 0, + }; +} + +var calls: [4096]u64 = undefined; +var call_count: usize = 0; +var specialized: [256]usize = @splat(0); +fn observed(input: []const u8, output: []u8) usize { + if (input[0] == 112) specialized[input[2]] += 1; + const count = core.nativeWindowPolicy(input, output); + core.rt.frameReset(); + return count; +} +fn event(value: u64) void { + calls[call_count] = value; + call_count += 1; +} +const storage = "Borrowed Café|# Document\nIssue #12 and [docs](https://example.invalid/docs)\n
MoreBody
\n
LessTail
|const x = 1;\nreturn x;\n// Done|https://example.invalid/issues/|2-3,1|4|Outer|Inner|\x00\xff"; +fn part(comptime index: usize) []const u8 { + return comptime blk: { + @setEvalBranchQuota(10_000); + var parts = std.mem.splitScalar(u8, storage, '|'); + for (0..index) |_| _ = parts.next(); + break :blk parts.next().?; + }; +} +const Image = struct { source: []const u8, image: u64, width: f32, height: f32 }; +const Row = struct { label: []const u8 }; +const Model = struct { + raw: []const u8 = part(0), + document: []const u8 = part(1), + program: []const u8 = part(2), + prefix: []const u8 = part(3), + lines: []const u8 = part(4), + removed: []const u8 = part(5), + number: f64 = 12.5, + identity: i64 = 9007199254740993, + step: i64 = 1, + truth: bool = true, + flags: []const bool = &.{ false, true }, + pictures: []const Image = &.{.{ .source = part(1)[0..10], .image = 4294967297, .width = 10.25, .height = 20.5 }}, + rows: []const Row = &.{ .{ .label = part(6) }, .{ .label = part(7) } }, + pub fn label(self: *const Model) []const u8 { + event(1); + return self.raw; + } + pub fn width(self: *const Model) f64 { + event(2); + return self.number; + } + pub fn height(self: *const Model) f64 { + event(3); + return self.number; + } + pub fn minimum(self: *const Model) f64 { + event(4); + return self.number; + } + pub fn grow(self: *const Model) f64 { + event(5); + return self.number; + } + pub fn key(self: *const Model) i64 { + event(6); + return self.identity; + } + pub fn global(self: *const Model) i64 { + event(7); + return self.identity; + } + pub fn body(self: *const Model) []const u8 { + event(8); + return self.document; + } + pub fn expanded(self: *const Model) []const bool { + event(9); + return self.flags; + } + pub fn images(self: *const Model) []const Image { + event(10); + return self.pictures; + } + pub fn base(self: *const Model) []const u8 { + event(11); + return self.prefix; + } + pub fn code(self: *const Model) []const u8 { + event(12); + return self.program; + } + pub fn numbers(self: *const Model) bool { + event(13); + return self.truth; + } + pub fn added(self: *const Model) []const u8 { + event(14); + return self.lines; + } + pub fn deleted(self: *const Model) []const u8 { + event(15); + return self.removed; + } + pub fn editable(self: *const Model) bool { + event(16); + return self.truth; + } + pub fn wrap(self: *const Model) bool { + event(17); + return !self.truth; + } + pub fn active(self: *const Model) i64 { + event(18); + return self.step; + } + pub fn title(self: *const Model) []const u8 { + event(19); + return self.raw; + } + pub fn poison(self: *const Model) []const u8 { + event(99); + return self.raw; + } +}; +const Msg = union(enum) { tick, link: []const u8, details: usize, edited: c.TextInputEvent }; +const Ui = c.Ui(Msg); +const unlimited = std.math.maxInt(usize); +const Outcome = struct { allocations: usize, resizes: usize, calls: usize }; +fn compare(comptime source: []const u8, model: Model, failure: usize, resize: usize) !Outcome { + const View = c.CompiledMarkupView(Model, Msg, source); + var aa = std.heap.ArenaAllocator.init(std.testing.allocator); + defer aa.deinit(); + var ab = std.heap.ArenaAllocator.init(std.testing.allocator); + defer ab.deinit(); + var fa = std.testing.FailingAllocator.init(aa.allocator(), .{ .fail_index = failure, .resize_fail_index = resize }); + var fb = std.testing.FailingAllocator.init(ab.allocator(), .{ .fail_index = failure, .resize_fail_index = resize }); + var left = Ui.init(fa.allocator()); + var right = Ui.init(fb.allocator()); + right.expression_policy = observed; + call_count = 0; + const a = View.build(&left, &model); + const before = calls; + const count = call_count; + call_count = 0; + const b = View.build(&right, &model); + errdefer std.debug.print("static content builders source={s} failure={d} resize={d}\n", .{ source, failure, resize }); + try exact(before[0..count], calls[0..call_count]); + try exact(a, b); + try exact(borrowed(a, storage), borrowed(b, storage)); + const at = left.finalize(a); + const bt = right.finalize(b); + if (at) |tree| { + const tree_b = try bt; + try exact(tree, tree_b); + try exact(borrowed(tree, storage), borrowed(tree_b, storage)); + } else |err| try std.testing.expectError(err, bt); + try exact(left.failed, right.failed); + try exact(left.virtualWindows(), right.virtualWindows()); + try exact(fa.alloc_index, fb.alloc_index); + try exact(fa.resize_index, fb.resize_index); + return .{ .allocations = fa.alloc_index, .resizes = fa.resize_index, .calls = count }; +} +fn check(comptime source: []const u8, model: Model, faults: bool) !void { + const result = try compare(source, model, unlimited, unlimited); + if (faults) { + for (0..result.allocations + 1) |index| _ = try compare(source, model, index, unlimited); + for (0..result.resizes + 1) |index| _ = try compare(source, model, unlimited, index); + } +} + +test "static markup content builders bind markdown source before fixed first-occurrence channels" { + _ = core.initialModel(); + specialized = @splat(0); + const source = ""; + _ = try compare(source, .{}, unlimited, unlimited); + try exact(@as([]const u64, &.{ 8, 9, 10, 11 }), calls[0..call_count]); + try check(source, .{}, true); + try check(source, .{ .prefix = "", .flags = &.{}, .pictures = &.{} }, true); + try check(source, .{ .document = "", .prefix = part(8) }, true); + try std.testing.expect(specialized[29] > 0); + try check("", .{}, true); + try check("{title}", .{}, true); +} + +test "static markup content builders keep markdown template captures and later callbacks" { + _ = core.initialModel(); + const source = "{title}"; + for ([_][]const u8{ "", " ", "https://example.invalid/issues/", "\x00\xff" }) |prefix| try check(source, .{ .prefix = prefix }, true); + try check(source, .{ .rows = &.{} }, true); +} + +test "static markup content builders fix code channels and keep stack line storage through publication" { + _ = core.initialModel(); + specialized = @splat(0); + const source = ""; + _ = try compare(source, .{}, unlimited, unlimited); + try exact(@as([]const u64, &.{ 12, 13, 14, 15, 16, 17, 2, 3, 4, 5, 1, 6, 7 }), calls[0..call_count]); + try check(source, .{}, true); + try check(source, .{ .truth = false, .raw = "", .identity = std.math.minInt(i64) }, true); + try check(source, .{ .program = "", .lines = "", .removed = "" }, true); + try std.testing.expect(specialized[30] > 0); + try check("", .{}, true); + try check("", .{}, true); +} + +test "static markup content builders continue code callbacks after invalid line metadata" { + _ = core.initialModel(); + const source = "{title}"; + for ([_][]const u8{ "", "1", "128", "1-128", "0", "129", "2-1", "1,,2", "1,2,x", "18446744073709551616", "\xff" }) |lines| { + try check(source, .{ .lines = lines }, true); + try check(source, .{ .removed = lines }, false); + } + try check(source, .{ .lines = "2", .removed = "2" }, true); + const template = "{title}"; + try check(template, .{}, true); + try check(template, .{ .removed = "x" }, true); +} + +test "static markup content builders evaluate stepper active and channels before allocating steps" { + _ = core.initialModel(); + specialized = @splat(0); + const source = "{title}Step {title}Fixed"; + _ = try compare(source, .{}, unlimited, unlimited); + try exact(@as([]const u64, &.{ 18, 6, 7, 1, 19, 19 }), calls[0..call_count]); + try check(source, .{}, true); + for ([_]i64{ std.math.minInt(i64), -9007199254740993, -1, 0, 2, 3, 9007199254740993, std.math.maxInt(i64) }) |step| try check(source, .{ .step = step }, true); + try check(source, .{ .raw = "", .identity = std.math.maxInt(i64) }, true); + try std.testing.expect(specialized[31] > 0); + try check("", .{}, true); + try check("Only{title}", .{}, true); +} + +test "static markup content builders interpolate slot steps in their lexical site scope" { + _ = core.initialModel(); + specialized = @splat(0); + const source = "{row.label}Then {row.label} {title}{title}"; + _ = try compare(source, .{}, unlimited, unlimited); + try exact(@as([]const u64, &.{ 19, 18, 19, 19, 18, 19, 18, 19 }), calls[0..call_count]); + try check(source, .{}, true); + try check(source, .{ .rows = &.{} }, true); + try check(source, .{ .rows = &.{.{ .label = part(8) }}, .raw = "", .step = -1 }, true); + try std.testing.expect(specialized[31] > 0); +} + +test "static markup content builders preserve borrowed sources labels and owned interpolation" { + _ = core.initialModel(); + const source = "{title}Step {title}"; + const View = c.CompiledMarkupView(Model, Msg, source); + var aa = std.heap.ArenaAllocator.init(std.testing.allocator); + defer aa.deinit(); + var ab = std.heap.ArenaAllocator.init(std.testing.allocator); + defer ab.deinit(); + var left = Ui.init(aa.allocator()); + var right = Ui.init(ab.allocator()); + right.expression_policy = observed; + const model: Model = .{}; + const a = View.build(&left, &model); + const b = View.build(&right, &model); + try exact(a, b); + try std.testing.expect(borrowed(a, storage) > 0); + try exact(borrowed(a, storage), borrowed(b, storage)); + try std.testing.expect(a.nodes[2].widget.semantics.label.ptr == model.raw.ptr); + try std.testing.expect(b.nodes[2].widget.semantics.label.ptr == model.raw.ptr); + try check(source, .{}, true); +} diff --git a/tests/ts-core/surface_layout_e2e_tests.zig b/tests/ts-core/surface_layout_e2e_tests.zig index 2d4edd11d..68fe3a88c 100644 --- a/tests/ts-core/surface_layout_e2e_tests.zig +++ b/tests/ts-core/surface_layout_e2e_tests.zig @@ -1015,4 +1015,5 @@ test { _ = @import("static_markup_specialized_e2e_tests.zig"); _ = @import("static_markup_typography_e2e_tests.zig"); _ = @import("static_markup_builders_e2e_tests.zig"); + _ = @import("static_markup_content_e2e_tests.zig"); }