812 lines
35 KiB
Zig
812 lines
35 KiB
Zig
//! End-to-end: a `.native` MARKUP VIEW over a genuinely transpiled core
|
|
//! (tests/ts-core/markup_fixture.ts + markup_view.native), through the
|
|
//! first-class `TsUiApp(core)` adapter — the committed TS model is the
|
|
//! app model and markup binds its emitted fields directly: a record
|
|
//! array through `for each` + `key`, an optional scalar through `<if>`,
|
|
//! a string-literal-union filter as an enum binding, bytes text, and
|
|
//! camelCase TS fields bound by their own names — the emitted struct keeps the TS spellings.
|
|
//!
|
|
//! On top of the view, the round's platform guarantees run through the
|
|
//! transpiled app unchanged:
|
|
//! - automation: headless widget verbs, the a11y snapshot, and
|
|
//! published screenshot artifacts (byte-identical on an unchanged
|
|
//! scene);
|
|
//! - record/replay: a session of journaled USER INPUT (menu commands
|
|
//! and raw pointer events on markup buttons) plus a `Cmd.now`
|
|
//! effect records byte-identically twice, replays with matching
|
|
//! state fingerprints, verified checkpoints, and verified PIXEL
|
|
//! screenshot marks, and never calls a host;
|
|
//! - process contract: two different transpiled cores run live side
|
|
//! by side (each staged core owns its rt kernel instance) — the
|
|
//! one-live-app-per-core-module contract, pinned from both sides.
|
|
|
|
const std = @import("std");
|
|
const builtin = @import("builtin");
|
|
const native_sdk = @import("native_sdk");
|
|
const board = @import("ts_markup_fixture");
|
|
const status_core = @import("ts_core_fixture");
|
|
|
|
const runtime_ns = native_sdk.runtime;
|
|
const canvas = native_sdk.canvas;
|
|
const geometry = native_sdk.geometry;
|
|
const automation = native_sdk.automation;
|
|
|
|
const Adapter = native_sdk.TsUiApp(board);
|
|
const App = Adapter.App;
|
|
const Bridge = Adapter.Host;
|
|
|
|
const board_markup = @embedFile("markup_view.native");
|
|
const CompiledBoardView = canvas.CompiledMarkupView(board.Model, board.Msg, board_markup);
|
|
|
|
const canvas_label = "ts-markup-canvas";
|
|
|
|
const board_views = [_]native_sdk.ShellView{
|
|
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
|
|
};
|
|
const board_windows = [_]native_sdk.ShellWindow{.{
|
|
.label = "main",
|
|
.title = "TS Board",
|
|
.width = 480,
|
|
.height = 360,
|
|
.views = &board_views,
|
|
}};
|
|
const board_scene: native_sdk.ShellConfig = .{ .windows = &board_windows };
|
|
|
|
fn boardCommand(name: []const u8) ?board.Msg {
|
|
if (std.mem.eql(u8, name, "board.add")) return .add;
|
|
if (std.mem.eql(u8, name, "board.cycle")) return .cycle;
|
|
if (std.mem.eql(u8, name, "board.clear")) return .clear;
|
|
if (std.mem.eql(u8, name, "board.stamp")) return .stamp;
|
|
return null;
|
|
}
|
|
|
|
fn boardOptions() App.Options {
|
|
return .{
|
|
.name = "ts-markup-e2e",
|
|
.scene = board_scene,
|
|
.canvas_label = canvas_label,
|
|
// The comptime-compiled engine over the EMITTED model — markup
|
|
// is the whole view tier of this app.
|
|
.view = CompiledBoardView.build,
|
|
.on_command = boardCommand,
|
|
};
|
|
}
|
|
|
|
const Harness = struct {
|
|
harness: *native_sdk.TestHarness(),
|
|
app_state: *App,
|
|
app: native_sdk.App,
|
|
clock: native_sdk.TestClock,
|
|
|
|
fn create() !*Harness {
|
|
return createConfigured(.{});
|
|
}
|
|
|
|
fn createRecorded(recorder: ?*runtime_ns.SessionRecorder) !*Harness {
|
|
return createConfigured(.{ .recorder = recorder });
|
|
}
|
|
|
|
const CreateConfig = struct {
|
|
/// Attaches BEFORE start so the journal holds the app_start and
|
|
/// installing-frame events.
|
|
recorder: ?*runtime_ns.SessionRecorder = null,
|
|
/// Adapter-owned knobs (boot images, env overrides).
|
|
core: Adapter.CoreOptions = .{},
|
|
/// The chrome geometry the null platform reports (delivered
|
|
/// through the core's chromeMsg channel before the first view
|
|
/// build).
|
|
chrome: native_sdk.WindowChrome = .{},
|
|
};
|
|
|
|
fn createConfigured(config: CreateConfig) !*Harness {
|
|
const self = try std.testing.allocator.create(Harness);
|
|
errdefer std.testing.allocator.destroy(self);
|
|
self.clock = .{};
|
|
self.clock.setWallMs(77_000);
|
|
self.harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
|
|
.size = geometry.SizeF.init(480, 360),
|
|
});
|
|
errdefer self.harness.destroy(std.testing.allocator);
|
|
self.harness.null_platform.gpu_surfaces = true;
|
|
self.harness.null_platform.image_decode = true;
|
|
self.harness.null_platform.window_chrome = config.chrome;
|
|
self.harness.runtime.options.session_recorder = config.recorder;
|
|
self.app_state = try std.testing.allocator.create(App);
|
|
errdefer std.testing.allocator.destroy(self.app_state);
|
|
self.app_state.* = Adapter.init(std.heap.page_allocator, config.core, boardOptions());
|
|
self.app_state.effects.clock = self.clock.clock();
|
|
self.app = self.app_state.app();
|
|
try self.harness.start(self.app);
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_frame = .{
|
|
.label = canvas_label,
|
|
.size = geometry.SizeF.init(480, 360),
|
|
.scale_factor = 1,
|
|
.frame_index = 1,
|
|
.timestamp_ns = 1_000_000,
|
|
} });
|
|
try std.testing.expect(self.app_state.installed);
|
|
return self;
|
|
}
|
|
|
|
fn destroy(self: *Harness) void {
|
|
self.app_state.deinit();
|
|
std.testing.allocator.destroy(self.app_state);
|
|
self.harness.destroy(std.testing.allocator);
|
|
std.testing.allocator.destroy(self);
|
|
}
|
|
|
|
fn menu(self: *Harness, name: []const u8) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .menu_command = .{ .name = name, .window_id = 1 } });
|
|
}
|
|
|
|
/// The rendered widget id of the first `kind` widget whose text
|
|
/// matches — markup trees carry deterministic structural ids.
|
|
fn findId(self: *Harness, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
|
|
return findKindText(self.app_state.tree.?.root, kind, text);
|
|
}
|
|
|
|
fn hasText(self: *Harness, text: []const u8) bool {
|
|
return findTextIn(self.app_state.tree.?.root, text);
|
|
}
|
|
|
|
/// Click a rendered widget through the AUTOMATION verb — the same
|
|
/// headless path `native automate` drives.
|
|
fn click(self: *Harness, id: canvas.ObjectId) !void {
|
|
var buffer: [96]u8 = undefined;
|
|
const command = try std.fmt.bufPrint(&buffer, "widget-click {s} {d}", .{ canvas_label, id });
|
|
try self.harness.runtime.dispatchAutomationCommand(self.app, command);
|
|
}
|
|
|
|
fn viewIndex(self: *Harness) !usize {
|
|
for (self.harness.runtime.views[0..self.harness.runtime.view_count], 0..) |view, index| {
|
|
if (std.mem.eql(u8, view.label, canvas_label)) return index;
|
|
}
|
|
return error.ViewNotFound;
|
|
}
|
|
|
|
/// The rendered center of a widget in view coordinates — for RAW
|
|
/// pointer events with test-fixed timestamps (the journaled-input
|
|
/// path record/replay pins; the automation click stamps real-clock
|
|
/// timestamps, which byte-identical recordings cannot carry).
|
|
fn aim(self: *Harness, id: canvas.ObjectId) !geometry.PointF {
|
|
const layout = self.harness.runtime.views[try self.viewIndex()].widgetLayoutTree();
|
|
const node = layout.findById(id) orelse return error.WidgetNotFound;
|
|
return node.frame.normalized().center();
|
|
}
|
|
|
|
fn pointerClick(self: *Harness, point: geometry.PointF, timestamp_ns: u64) !void {
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .pointer_down,
|
|
.timestamp_ns = timestamp_ns,
|
|
.x = point.x,
|
|
.y = point.y,
|
|
.button = 0,
|
|
} });
|
|
try self.harness.runtime.dispatchPlatformEvent(self.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .pointer_up,
|
|
.timestamp_ns = timestamp_ns + 1_000_000,
|
|
.x = point.x,
|
|
.y = point.y,
|
|
.button = 0,
|
|
} });
|
|
}
|
|
};
|
|
|
|
fn findKindText(widget: canvas.Widget, kind: canvas.WidgetKind, text: []const u8) ?canvas.ObjectId {
|
|
if (widget.kind == kind and std.mem.eql(u8, widget.text, text)) return widget.id;
|
|
for (widget.children) |child| {
|
|
if (findKindText(child, kind, text)) |id| return id;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
fn findTextIn(widget: canvas.Widget, text: []const u8) bool {
|
|
if (std.mem.indexOf(u8, widget.text, text) != null) return true;
|
|
for (widget.children) |child| {
|
|
if (findTextIn(child, text)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ------------------------------------------------------- markup binding
|
|
|
|
test "markup binds the transpiled model: lists, optionals, enums, and the TS field names" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
|
|
// The boot model rendered through markup: bytes text, the enum
|
|
// filter as its tag name, and the camelCase `nextId` bound as
|
|
// `{nextId}` — markup binds the model's field names exactly as the
|
|
// TS source wrote them.
|
|
try std.testing.expect(h.hasText("ready filter all next 1"));
|
|
try std.testing.expect(h.hasText("done 0"));
|
|
try std.testing.expect(h.hasText("no tasks"));
|
|
try std.testing.expect(!h.hasText("picked"));
|
|
|
|
// Two adds: the `for each` renders the record-array items with
|
|
// their fields, keyed by id; the else branch disappears.
|
|
try h.click(h.findId(.button, "Add").?);
|
|
try h.click(h.findId(.button, "Add").?);
|
|
try std.testing.expectEqual(@as(usize, 2), Bridge.model().tasks.len);
|
|
try std.testing.expectEqual(@as(usize, 2), h.app_state.model.tasks.len);
|
|
try std.testing.expect(h.hasText("beta #1"));
|
|
try std.testing.expect(h.hasText("gamma #2"));
|
|
try std.testing.expect(h.hasText("ready filter all next 3"));
|
|
try std.testing.expect(!h.hasText("no tasks"));
|
|
|
|
// A payload built from the pointer item flips exactly that row: the
|
|
// done badge appears and the derived count re-renders.
|
|
try h.click(h.findId(.button, "Flip").?);
|
|
try std.testing.expect(Bridge.model().tasks[0].done);
|
|
try std.testing.expect(!Bridge.model().tasks[1].done);
|
|
try std.testing.expect(h.hasText("done 1"));
|
|
try std.testing.expect(findTextIn(h.app_state.tree.?.root, "done") and h.findId(.badge, "done") != null);
|
|
|
|
// The optional gate opens on pick (f64 payload coerced from the
|
|
// integer item binding) and the enum cycles through its members.
|
|
try h.click(h.findId(.button, "Pick").?);
|
|
try std.testing.expect(h.hasText("picked 1"));
|
|
try h.click(h.findId(.button, "Cycle").?);
|
|
try std.testing.expect(h.hasText("filter open"));
|
|
|
|
// Clear returns to the empty state: the else branch and the new
|
|
// banner bytes.
|
|
try h.click(h.findId(.button, "Clear").?);
|
|
try std.testing.expect(h.hasText("cleared filter open next 3"));
|
|
try std.testing.expect(h.hasText("no tasks"));
|
|
try std.testing.expect(!h.hasText("picked"));
|
|
}
|
|
|
|
test "the runtime markup interpreter builds the emitted model exactly like the compiled engine" {
|
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
|
defer arena_state.deinit();
|
|
const arena = arena_state.allocator();
|
|
|
|
const first = board.Task{ .id = 1, .title = "beta", .done = true };
|
|
const second = board.Task{ .id = 2, .title = "gamma", .done = false };
|
|
const tasks = [_]*const board.Task{ &first, &second };
|
|
const model = board.Model{
|
|
.filter = .open,
|
|
.nextId = 3,
|
|
.doneCount = 1,
|
|
.banner = "ready",
|
|
.selected = 2,
|
|
.tasks = &tasks,
|
|
.stampMs = -1,
|
|
.draft = "",
|
|
.canvasWidth = 0,
|
|
.dark = false,
|
|
.chromeTop = 0,
|
|
};
|
|
|
|
const BoardUi = canvas.Ui(board.Msg);
|
|
var interpreter_view = try canvas.MarkupView(board.Model, board.Msg).init(arena, board_markup);
|
|
var interpreter_ui = BoardUi.init(arena);
|
|
const interpreted = try interpreter_ui.finalize(try interpreter_view.build(&interpreter_ui, &model));
|
|
var compiled_ui = BoardUi.init(arena);
|
|
const compiled = try compiled_ui.finalize(CompiledBoardView.build(&compiled_ui, &model));
|
|
|
|
var interpreted_texts: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer interpreted_texts.deinit(std.testing.allocator);
|
|
var compiled_texts: std.ArrayListUnmanaged(u8) = .empty;
|
|
defer compiled_texts.deinit(std.testing.allocator);
|
|
try collectTexts(interpreted.root, &interpreted_texts, std.testing.allocator);
|
|
try collectTexts(compiled.root, &compiled_texts, std.testing.allocator);
|
|
try std.testing.expectEqualStrings(interpreted_texts.items, compiled_texts.items);
|
|
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "ready filter open next 3") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "picked 2") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, compiled_texts.items, "beta #1") != null);
|
|
}
|
|
|
|
fn collectTexts(widget: canvas.Widget, out: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator) !void {
|
|
try out.appendSlice(allocator, widget.text);
|
|
try out.append(allocator, '\n');
|
|
for (widget.children) |child| {
|
|
try collectTexts(child, out, allocator);
|
|
}
|
|
}
|
|
|
|
test "markup text input reaches the transpiled core and re-renders the view" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
|
|
// The declared-union translation end to end: the markup text field's
|
|
// on-input arm carries the CORE-DECLARED TextInputEvent mirror; the
|
|
// runtime keyboard path builds the Msg through the translated
|
|
// constructor, the core's update splices its committed draft bytes,
|
|
// and the rebuilt view renders the new model.
|
|
try std.testing.expect(h.hasText("draft []"));
|
|
const field = h.findId(.text_field, "").?;
|
|
try h.click(field); // focus the field (the automation click path)
|
|
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .text_input,
|
|
.text = "hi",
|
|
} });
|
|
try std.testing.expectEqualStrings("hi", Bridge.model().draft);
|
|
try std.testing.expect(h.hasText("draft [hi]"));
|
|
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .text_input,
|
|
.text = " there",
|
|
} });
|
|
try std.testing.expect(h.hasText("draft [hi there]"));
|
|
|
|
// Backspace routes the delete_backward verb arm; the core drops one
|
|
// byte from its committed draft.
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .key_down,
|
|
.key = "backspace",
|
|
} });
|
|
try std.testing.expectEqualStrings("hi ther", Bridge.model().draft);
|
|
try std.testing.expect(h.hasText("draft [hi ther]"));
|
|
|
|
// Caret-only events translate (move_caret arm) without touching the
|
|
// model - the fixture's reducer ignores them by design.
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .key_down,
|
|
.key = "arrowleft",
|
|
} });
|
|
try std.testing.expectEqualStrings("hi ther", Bridge.model().draft);
|
|
|
|
// And the field itself renders the committed draft (value binding).
|
|
try std.testing.expect(h.findId(.text_field, "hi ther") != null);
|
|
}
|
|
|
|
test "automation set_text drives a transpiled-core text field (select-all sentinel translates)" {
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
|
|
// Seed the field through real typing so the replace verb's select-all
|
|
// has a selection to make.
|
|
const field = h.findId(.text_field, "").?;
|
|
try h.click(field);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .text_input,
|
|
.text = "hi",
|
|
} });
|
|
try std.testing.expectEqualStrings("hi", Bridge.model().draft);
|
|
|
|
// The automation replace verb routes through the REAL input path:
|
|
// focus, cmd/ctrl+A, then the replacement text. Select-all synthesizes
|
|
// `set_selection` carrying the `focus = maxInt(usize)` "to the end"
|
|
// sentinel, and the declared-union translation must SATURATE it into
|
|
// the core's i64 field class — @intCast here panicked "integer does
|
|
// not fit in destination type" on every transpiled-core text field
|
|
// (the live-GUI smoke's soundboard-ts search crash).
|
|
var buffer: [96]u8 = undefined;
|
|
const command = try std.fmt.bufPrint(&buffer, "widget-action {s} {d} set-text yo", .{ canvas_label, field });
|
|
try h.harness.runtime.dispatchAutomationCommand(h.app, command);
|
|
|
|
// The mirror heard the select-all (the fixture's deliberately
|
|
// append-only reducer ignores selection verbs) and then the
|
|
// replacement text — the whole sequence delivered, nothing panicked.
|
|
try std.testing.expectEqualStrings("hiyo", Bridge.model().draft);
|
|
}
|
|
|
|
// ------------------------------------------------- host-event channels
|
|
|
|
test "the wiring channels drive the core: frame, key, appearance, and chrome" {
|
|
const h = try Harness.createConfigured(.{
|
|
.chrome = .{
|
|
.insets = .{ .top = 52, .left = 78 },
|
|
.buttons = geometry.RectF.init(12, 14, 54, 16),
|
|
},
|
|
});
|
|
defer h.destroy();
|
|
|
|
// frameMsg: presented frames dispatch through the core's frame arm
|
|
// (the installing frame is excluded by the UiApp contract, so the
|
|
// first PRESENTED frame corrects the model's seed width — the
|
|
// album-grid derivation shape) and a live resize re-dispatches; the
|
|
// core returns null for a same-width frame, so the channel starves
|
|
// when nothing changes (the idle law).
|
|
try std.testing.expectEqual(@as(i64, 0), Bridge.model().canvasWidth);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_frame = .{
|
|
.label = canvas_label,
|
|
.size = geometry.SizeF.init(480, 360),
|
|
.scale_factor = 1,
|
|
.frame_index = 2,
|
|
.timestamp_ns = 2_000_000,
|
|
} });
|
|
try std.testing.expectEqual(@as(i64, 480), Bridge.model().canvasWidth);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_frame = .{
|
|
.label = canvas_label,
|
|
.size = geometry.SizeF.init(512, 360),
|
|
.scale_factor = 1,
|
|
.frame_index = 3,
|
|
.timestamp_ns = 3_000_000,
|
|
} });
|
|
try std.testing.expectEqual(@as(i64, 512), Bridge.model().canvasWidth);
|
|
|
|
// chromeMsg: the hidden-titlebar geometry landed BEFORE the first
|
|
// view build, built by field name into the declared record.
|
|
try std.testing.expectEqual(@as(f64, 52), Bridge.model().chromeTop);
|
|
|
|
// appearanceMsg: the system flip lands as an ordinary Msg; the
|
|
// declared light/dark enum matches by member name.
|
|
try std.testing.expect(!Bridge.model().dark);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .appearance_changed = .{ .color_scheme = .dark } });
|
|
try std.testing.expect(Bridge.model().dark);
|
|
|
|
// keyMsg: the app-level key FALLBACK — the key name arrives
|
|
// lowercased, so the core's `key.key === "space"` matches the
|
|
// platform's "Space" spelling, and the modifier booleans ride along.
|
|
try std.testing.expect(Bridge.model().filter == .all);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .{ .gpu_surface_input = .{
|
|
.window_id = 1,
|
|
.label = canvas_label,
|
|
.kind = .key_down,
|
|
.key = "Space",
|
|
} });
|
|
try std.testing.expect(Bridge.model().filter == .open);
|
|
try std.testing.expect(h.hasText("filter open"));
|
|
}
|
|
|
|
test "boot images register and launch env overrides dispatch at install" {
|
|
// A tiny PNG through the engine's own encoder — the register path
|
|
// the wiring's app.zon assets ride, no side door into the registry.
|
|
const rgba = [_]u8{ 255, 0, 0, 255 } ** 4;
|
|
var encoded: [256]u8 = undefined;
|
|
var png_writer = std.Io.Writer.fixed(&encoded);
|
|
try canvas.png.writeRgba8(&png_writer, 2, 2, &rgba);
|
|
|
|
const boot_images = [_]Adapter.BootImage{.{ .id = 7, .bytes = png_writer.buffered() }};
|
|
const env_values = [_]Adapter.EnvValue{.{ .msg = "banner_set", .value = "from the launch env" }};
|
|
const h = try Harness.createConfigured(.{ .core = .{
|
|
.boot_images = &boot_images,
|
|
.env_values = &env_values,
|
|
} });
|
|
defer h.destroy();
|
|
|
|
// The image registered on the installing frame (init_fx semantics —
|
|
// before the first view build).
|
|
try std.testing.expectEqual(@as(usize, 1), h.harness.runtime.canvas_image_count);
|
|
|
|
// The env override dispatched through its one-bytes-field arm right
|
|
// after the boot command: the committed model and the rendered view
|
|
// both carry it before any user input.
|
|
try std.testing.expectEqualStrings("from the launch env", Bridge.model().banner);
|
|
try std.testing.expect(h.hasText("from the launch env"));
|
|
}
|
|
|
|
// ----------------------------------------------------------- automation
|
|
|
|
test "the automation surface drives the transpiled markup app headlessly" {
|
|
const directory = ".zig-cache/tmp/ts-markup-automation";
|
|
std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
|
|
defer std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
|
|
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
h.harness.runtime.options.automation = automation.Server.init(std.testing.io, directory, "TS Board");
|
|
|
|
// The a11y snapshot names the markup-rendered controls.
|
|
const snapshot = h.harness.runtime.automationSnapshot("TS Board");
|
|
var a11y_buffer: [8192]u8 = undefined;
|
|
var a11y_writer = std.Io.Writer.fixed(&a11y_buffer);
|
|
try automation.snapshot.writeA11yText(snapshot, &a11y_writer);
|
|
try std.testing.expect(std.mem.indexOf(u8, a11y_writer.buffered(), "role=button name=\"Add\"") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, a11y_writer.buffered(), "no tasks") != null);
|
|
|
|
// Menu commands and widget verbs drive the core; the refreshed
|
|
// snapshot reports the new state.
|
|
try h.menu("board.add");
|
|
try h.click(h.findId(.button, "Flip").?);
|
|
try std.testing.expect(Bridge.model().tasks[0].done);
|
|
const after = h.harness.runtime.automationSnapshot("TS Board");
|
|
var after_buffer: [8192]u8 = undefined;
|
|
var after_writer = std.Io.Writer.fixed(&after_buffer);
|
|
try automation.snapshot.writeA11yText(after, &after_writer);
|
|
try std.testing.expect(std.mem.indexOf(u8, after_writer.buffered(), "beta #1") != null);
|
|
|
|
// The screenshot verb publishes a parseable PNG artifact, and an
|
|
// unchanged scene captures byte-identically — the pixel-stability
|
|
// pin for the deterministic reference renderer over a TS core.
|
|
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot " ++ canvas_label);
|
|
const artifact_path = directory ++ "/screenshot-" ++ canvas_label ++ ".png";
|
|
const first = try readAutomationFile(std.testing.allocator, std.testing.io, artifact_path);
|
|
defer std.testing.allocator.free(first);
|
|
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot " ++ canvas_label);
|
|
const second = try readAutomationFile(std.testing.allocator, std.testing.io, artifact_path);
|
|
defer std.testing.allocator.free(second);
|
|
try std.testing.expect(first.len > 0);
|
|
try std.testing.expectEqualSlices(u8, first, second);
|
|
|
|
// A model change changes the pixels.
|
|
try h.menu("board.clear");
|
|
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot " ++ canvas_label);
|
|
const changed = try readAutomationFile(std.testing.allocator, std.testing.io, artifact_path);
|
|
defer std.testing.allocator.free(changed);
|
|
try std.testing.expect(!std.mem.eql(u8, first, changed));
|
|
}
|
|
|
|
fn readAutomationFile(allocator: std.mem.Allocator, io: std.Io, path: []const u8) ![]u8 {
|
|
var file = try std.Io.Dir.cwd().openFile(io, path, .{});
|
|
defer file.close(io);
|
|
var read_buffer: [4096]u8 = undefined;
|
|
var reader = file.reader(io, &read_buffer);
|
|
return reader.interface.allocRemaining(allocator, .limited(8 * 1024 * 1024));
|
|
}
|
|
|
|
// -------------------------------------------------------- record / replay
|
|
|
|
const JournalBuffer = struct {
|
|
bytes: [256 * 1024]u8 = undefined,
|
|
len: usize = 0,
|
|
|
|
fn sink(self: *JournalBuffer) runtime_ns.SessionRecorderSink {
|
|
return .{ .context = self, .write_fn = write };
|
|
}
|
|
|
|
fn write(context: *anyopaque, bytes: []const u8) anyerror!void {
|
|
const self: *JournalBuffer = @ptrCast(@alignCast(context));
|
|
if (self.len + bytes.len > self.bytes.len) return error.NoSpaceLeft;
|
|
@memcpy(self.bytes[self.len .. self.len + bytes.len], bytes);
|
|
self.len += bytes.len;
|
|
}
|
|
|
|
fn journalBytes(self: *const JournalBuffer) []const u8 {
|
|
return self.bytes[0..self.len];
|
|
}
|
|
};
|
|
|
|
/// A value snapshot of the board model (committed slices live in the
|
|
/// core's heap — copy what outlives a session).
|
|
const BoardSnapshot = struct {
|
|
task_count: usize,
|
|
first_done: bool,
|
|
doneCount: i64,
|
|
nextId: i64,
|
|
stampMs: f64,
|
|
banner: [16]u8,
|
|
banner_len: usize,
|
|
|
|
fn take() BoardSnapshot {
|
|
const m = Bridge.model();
|
|
var snapshot: BoardSnapshot = .{
|
|
.task_count = m.tasks.len,
|
|
.first_done = m.tasks.len > 0 and m.tasks[0].done,
|
|
.doneCount = m.doneCount,
|
|
.nextId = m.nextId,
|
|
.stampMs = m.stampMs,
|
|
.banner = [_]u8{0} ** 16,
|
|
.banner_len = @min(m.banner.len, 16),
|
|
};
|
|
@memcpy(snapshot.banner[0..snapshot.banner_len], m.banner[0..snapshot.banner_len]);
|
|
return snapshot;
|
|
}
|
|
};
|
|
|
|
/// Record the reference markup session: user input as journaled menu
|
|
/// commands AND raw pointer events on a markup button (test-fixed
|
|
/// timestamps, so two recordings are byte-identical), one `Cmd.now`
|
|
/// effect through the journaled clock, per-frame fingerprint
|
|
/// checkpoints, and one PIXEL screenshot mark through the automation
|
|
/// verb.
|
|
fn recordBoardSession(buffer: *JournalBuffer, screenshot_dir: []const u8) !struct {
|
|
snapshot: BoardSnapshot,
|
|
fingerprint: u64,
|
|
} {
|
|
const recorder = try std.heap.page_allocator.create(runtime_ns.SessionRecorder);
|
|
defer std.heap.page_allocator.destroy(recorder);
|
|
recorder.* = runtime_ns.SessionRecorder.init(buffer.sink());
|
|
recorder.begin(.{ .platform_name = "test", .app_name = "ts-markup-e2e", .window_width = 480, .window_height = 360 });
|
|
|
|
const h = try Harness.createRecorded(recorder);
|
|
defer h.destroy();
|
|
h.harness.runtime.options.automation = automation.Server.init(std.testing.io, screenshot_dir, "TS Board");
|
|
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
// Journaled user input: menu commands add two tasks.
|
|
try h.menu("board.add");
|
|
try h.menu("board.add");
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
// Journaled user input: a RAW pointer click on the first row's Flip
|
|
// button (fixed timestamps; the aim point derives from the
|
|
// deterministic layout, so both recordings compute the same one).
|
|
const flip = h.findId(.button, "Flip").?;
|
|
try h.pointerClick(try h.aim(flip), 5_000_000);
|
|
try std.testing.expect(Bridge.model().tasks[0].done);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
// One effect: Cmd.now reads the journaled clock synchronously.
|
|
try h.menu("board.stamp");
|
|
try std.testing.expectEqual(@as(f64, 77_000), Bridge.model().stampMs);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
// A screenshot taken during a recorded session marks a pixel
|
|
// checkpoint the replay must re-render to the same hash.
|
|
try h.harness.runtime.dispatchAutomationCommand(h.app, "screenshot " ++ canvas_label);
|
|
try h.harness.runtime.dispatchPlatformEvent(h.app, .frame_requested);
|
|
|
|
recorder.finish();
|
|
try std.testing.expect(!recorder.failed);
|
|
return .{ .snapshot = BoardSnapshot.take(), .fingerprint = h.harness.runtime.sessionStateFingerprint() };
|
|
}
|
|
|
|
test "a recorded markup session replays byte-identically with verified fingerprints and pixel marks" {
|
|
const directory = ".zig-cache/tmp/ts-markup-replay";
|
|
std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
|
|
defer std.Io.Dir.cwd().deleteTree(std.testing.io, directory) catch {};
|
|
|
|
const buffer = try std.heap.page_allocator.create(JournalBuffer);
|
|
defer std.heap.page_allocator.destroy(buffer);
|
|
buffer.len = 0;
|
|
const recorded = try recordBoardSession(buffer, directory);
|
|
try std.testing.expectEqual(@as(usize, 2), recorded.snapshot.task_count);
|
|
try std.testing.expect(recorded.snapshot.first_done);
|
|
try std.testing.expectEqual(@as(i64, 1), recorded.snapshot.doneCount);
|
|
try std.testing.expectEqual(@as(f64, 77_000), recorded.snapshot.stampMs);
|
|
|
|
// Determinism pin: the same driven session records byte-identical
|
|
// journal bytes — pointer aim points, checkpoint fingerprints, and
|
|
// the screenshot mark's pixel hash included.
|
|
const second = try std.heap.page_allocator.create(JournalBuffer);
|
|
defer std.heap.page_allocator.destroy(second);
|
|
second.len = 0;
|
|
const recorded_again = try recordBoardSession(second, directory);
|
|
try std.testing.expectEqualDeep(recorded.snapshot, recorded_again.snapshot);
|
|
try std.testing.expectEqualSlices(u8, buffer.journalBytes(), second.journalBytes());
|
|
|
|
// Replay into a fresh app with NO automation server and NO host
|
|
// binding: events (pointer clicks included) re-dispatch from the
|
|
// journal, the clock read feeds from its record, checkpoints and
|
|
// the pixel screenshot mark verify against the re-rendered frames.
|
|
const harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
|
|
.size = geometry.SizeF.init(480, 360),
|
|
});
|
|
defer harness.destroy(std.testing.allocator);
|
|
harness.null_platform.gpu_surfaces = true;
|
|
const app_state = try std.testing.allocator.create(App);
|
|
defer std.testing.allocator.destroy(app_state);
|
|
app_state.* = Adapter.init(std.heap.page_allocator, .{}, boardOptions());
|
|
defer app_state.deinit();
|
|
|
|
const report = try runtime_ns.replaySession(&harness.runtime, app_state.app(), buffer.journalBytes(), .{
|
|
.verify = true,
|
|
.require_same_platform = false,
|
|
});
|
|
try std.testing.expect(report.ok());
|
|
try std.testing.expect(report.events_replayed > 0);
|
|
try std.testing.expect(report.checkpoints_verified > 0);
|
|
try std.testing.expectEqual(@as(u64, 1), report.screenshots_verified);
|
|
// The one journaled effect result is the Cmd.now clock read.
|
|
try std.testing.expectEqual(@as(u64, 1), report.effects_fed);
|
|
try std.testing.expectEqualDeep(recorded.snapshot, BoardSnapshot.take());
|
|
try std.testing.expectEqual(recorded.fingerprint, harness.runtime.sessionStateFingerprint());
|
|
}
|
|
|
|
// ------------------------------------------------- two live cores
|
|
|
|
/// A minimal host stub for the status core's boot request (the markup
|
|
/// core performs no host calls).
|
|
const CoexistStub = struct {
|
|
var request_count: usize = 0;
|
|
var context: u8 = 0;
|
|
|
|
fn send(ctx: *anyopaque, name: []const u8, payload: []const u8) void {
|
|
_ = ctx;
|
|
_ = name;
|
|
_ = payload;
|
|
}
|
|
|
|
fn request(ctx: *anyopaque, name: []const u8, key: u64, payload: []const u8) void {
|
|
_ = ctx;
|
|
_ = name;
|
|
_ = key;
|
|
_ = payload;
|
|
request_count += 1;
|
|
}
|
|
|
|
fn cancelNotice(ctx: *anyopaque, key: u64) void {
|
|
_ = ctx;
|
|
_ = key;
|
|
}
|
|
|
|
fn binding() native_sdk.HostCallBinding {
|
|
return .{ .context = @ptrCast(&context), .send_fn = send, .request_fn = request, .cancel_fn = cancelNotice };
|
|
}
|
|
};
|
|
|
|
const StatusAdapter = native_sdk.TsUiApp(status_core);
|
|
const StatusApp = StatusAdapter.App;
|
|
|
|
const status_canvas_label = "ts-core-canvas";
|
|
const status_views = [_]native_sdk.ShellView{
|
|
.{ .label = status_canvas_label, .kind = .gpu_surface, .fill = true, .gpu_backend = .metal },
|
|
};
|
|
const status_windows = [_]native_sdk.ShellWindow{.{
|
|
.label = "main",
|
|
.title = "TS Core",
|
|
.width = 400,
|
|
.height = 300,
|
|
.views = &status_views,
|
|
}};
|
|
const status_scene: native_sdk.ShellConfig = .{ .windows = &status_windows };
|
|
|
|
fn statusView(ui: *StatusApp.Ui, model: *const status_core.Model) StatusApp.Ui.Node {
|
|
return ui.column(.{ .gap = 4, .padding = 8 }, .{
|
|
ui.text(.{}, ui.fmt("ticks {d}", .{model.ticks})),
|
|
});
|
|
}
|
|
|
|
fn statusCommand(name: []const u8) ?status_core.Msg {
|
|
if (std.mem.eql(u8, name, "core.stamp")) return .stamp;
|
|
if (std.mem.eql(u8, name, "core.toggle")) return .toggle;
|
|
return null;
|
|
}
|
|
|
|
test "two live transpiled cores coexist: each staged core owns its kernel and committed root" {
|
|
// The markup board app...
|
|
const h = try Harness.create();
|
|
defer h.destroy();
|
|
|
|
// ...and the status-poller app from the OTHER emitted core, live in
|
|
// the same process at the same time.
|
|
CoexistStub.request_count = 0;
|
|
var status_clock: native_sdk.TestClock = .{};
|
|
status_clock.setWallMs(90_000);
|
|
const status_harness = try native_sdk.TestHarness().create(std.testing.allocator, .{
|
|
.size = geometry.SizeF.init(400, 300),
|
|
});
|
|
defer status_harness.destroy(std.testing.allocator);
|
|
status_harness.null_platform.gpu_surfaces = true;
|
|
const status_state = try std.testing.allocator.create(StatusApp);
|
|
defer std.testing.allocator.destroy(status_state);
|
|
status_state.* = StatusAdapter.init(std.heap.page_allocator, .{}, .{
|
|
.name = "ts-core-coexist",
|
|
.scene = status_scene,
|
|
.canvas_label = status_canvas_label,
|
|
.view = statusView,
|
|
.on_command = statusCommand,
|
|
});
|
|
defer status_state.deinit();
|
|
status_state.effects.bindHostCalls(CoexistStub.binding());
|
|
status_state.effects.clock = status_clock.clock();
|
|
const status_app = status_state.app();
|
|
try status_harness.start(status_app);
|
|
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .gpu_surface_frame = .{
|
|
.label = status_canvas_label,
|
|
.size = geometry.SizeF.init(400, 300),
|
|
.scale_factor = 1,
|
|
.frame_index = 1,
|
|
.timestamp_ns = 1_000_000,
|
|
} });
|
|
try std.testing.expect(status_state.installed);
|
|
try std.testing.expectEqual(@as(usize, 1), CoexistStub.request_count);
|
|
|
|
// Interleaved dispatches: each core's committed model progresses
|
|
// independently — no shared frame arena, no shared heap, no shared
|
|
// bridge tables.
|
|
try h.click(h.findId(.button, "Add").?);
|
|
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .menu_command = .{ .name = "core.stamp", .window_id = 1 } });
|
|
try h.click(h.findId(.button, "Add").?);
|
|
try status_harness.runtime.dispatchPlatformEvent(status_app, .{ .menu_command = .{ .name = "core.toggle", .window_id = 1 } });
|
|
|
|
try std.testing.expectEqual(@as(usize, 2), Bridge.model().tasks.len);
|
|
try std.testing.expectEqualStrings("beta", Bridge.model().tasks[0].title);
|
|
try std.testing.expectEqual(@as(f64, 90_000), StatusAdapter.Host.model().stampMs);
|
|
try std.testing.expect(!StatusAdapter.Host.model().polling);
|
|
|
|
// And the markup app still renders its own core's state.
|
|
try std.testing.expect(h.hasText("beta #1"));
|
|
try std.testing.expect(h.hasText("gamma #2"));
|
|
}
|