707 lines
30 KiB
TypeScript
707 lines
30 KiB
TypeScript
// End-to-end effects gate: transpile a Cmd-bearing core, build it against
|
|
// the rt kernel, and drive the real dispatch cycle — update → commit →
|
|
// consume cmd bytes → frameReset — asserting the exact effect log the
|
|
// versioned Cmd wire format (rt.cmd_format_version) prescribes.
|
|
// Skipped when no zig toolchain is on PATH.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { transpile } from "./helpers.ts";
|
|
|
|
const pkg = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
const hasZig = spawnSync("zig", ["version"], { stdio: "ignore" }).status === 0;
|
|
|
|
const core = `
|
|
import { Cmd } from "@native-sdk/core";
|
|
|
|
export interface Model { readonly count: number; readonly saved: number; }
|
|
|
|
export type Msg =
|
|
| { readonly kind: "add" }
|
|
| { readonly kind: "save" }
|
|
| { readonly kind: "stamp" }
|
|
| { readonly kind: "tick"; readonly at: number }
|
|
| { readonly kind: "ship" };
|
|
|
|
export function initialModel(): Model {
|
|
return { count: 0, saved: 0 };
|
|
}
|
|
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "add": return { ...model, count: model.count + 1 };
|
|
case "save": return [{ ...model, saved: model.count }, Cmd.persist()];
|
|
case "stamp": return [model, Cmd.now("tick")];
|
|
case "tick": return { ...model, count: msg.at };
|
|
case "ship": return [model, Cmd.batch([Cmd.persist(), Cmd.host("beep", model.count, 2)])];
|
|
}
|
|
}
|
|
`;
|
|
|
|
// Drives the emitted core exactly the way a host does: the cmd bytes are
|
|
// frame-arena resident, so each dispatch commits the model, copies the cmd
|
|
// bytes into the host-side effect log, and only then resets the frame.
|
|
const harness = `
|
|
const std = @import("std");
|
|
const core = @import("core.zig");
|
|
const rt = core.rt;
|
|
|
|
var g_model: *const core.Model = undefined;
|
|
var g_effects: [256]u8 = undefined;
|
|
var g_effects_len: usize = 0;
|
|
|
|
fn dispatch(msg: core.Msg) []const u8 {
|
|
const r = core.update(g_model, msg);
|
|
g_model = core.commitModelRoot(r.model);
|
|
const start = g_effects_len;
|
|
@memcpy(g_effects[start..][0..r.cmd.len], r.cmd);
|
|
g_effects_len += r.cmd.len;
|
|
rt.frameReset();
|
|
return g_effects[start..g_effects_len];
|
|
}
|
|
|
|
test "cmd bytes flow through update -> commit -> effect log" {
|
|
// v2 is additive: the v1 op records asserted below are byte-identical
|
|
// under the bumped version.
|
|
try std.testing.expectEqual(@as(u32, 2), rt.cmd_format_version);
|
|
|
|
rt.resetAll();
|
|
g_model = core.commitModelRoot(core.initialModel());
|
|
rt.frameReset();
|
|
|
|
// Bare model return: no effect bytes, model still commits.
|
|
const add = dispatch(.add);
|
|
try std.testing.expectEqual(@as(usize, 0), add.len);
|
|
try std.testing.expectEqual(@as(f64, 1), g_model.count);
|
|
|
|
// persist: [op 0x01]; the tuple's model slot commits too.
|
|
const save = dispatch(.save);
|
|
try std.testing.expectEqualSlices(u8, &.{0x01}, save);
|
|
try std.testing.expectEqual(@as(f64, 1), g_model.saved);
|
|
|
|
// now: [op 0x02][msg_tag], tag = declaration-order index of .tick.
|
|
const stamp = dispatch(.stamp);
|
|
const tick_tag: u8 = @intFromEnum(std.meta.Tag(core.Msg).tick);
|
|
try std.testing.expectEqualSlices(u8, &.{ 0x02, tick_tag }, stamp);
|
|
|
|
// The requested timestamp comes back as a plain Msg.
|
|
const tick = dispatch(.{ .tick = 42 });
|
|
try std.testing.expectEqual(@as(usize, 0), tick.len);
|
|
try std.testing.expectEqual(@as(f64, 42), g_model.count);
|
|
|
|
// batch = concatenation: persist ++ host("beep", 42, 2).
|
|
var expected: [24]u8 = undefined;
|
|
expected[0] = 0x01;
|
|
expected[1] = 0x03;
|
|
expected[2] = 4;
|
|
@memcpy(expected[3..7], "beep");
|
|
expected[7] = 2;
|
|
std.mem.writeInt(u64, expected[8..16], @bitCast(@as(f64, 42)), .little);
|
|
std.mem.writeInt(u64, expected[16..24], @bitCast(@as(f64, 2)), .little);
|
|
const ship = dispatch(.ship);
|
|
try std.testing.expectEqualSlices(u8, &expected, ship);
|
|
|
|
// The accumulated host-side log is every dispatch's bytes in order.
|
|
try std.testing.expectEqual(@as(usize, 1 + 2 + 24), g_effects_len);
|
|
}
|
|
`;
|
|
|
|
test("effect log through the real dispatch cycle", { skip: !hasZig, timeout: 300_000 }, () => {
|
|
const result = transpile(core);
|
|
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
|
|
assert.equal(result.ok, true, `transpile failed\n${result.typeErrors.join("\n")}\n${details}`);
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-effects-"));
|
|
try {
|
|
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
|
|
fs.writeFileSync(path.join(work, "core.zig"), result.zig!);
|
|
fs.writeFileSync(path.join(work, "harness.zig"), harness);
|
|
try {
|
|
execFileSync("zig", ["test", "harness.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
|
|
} catch (e) {
|
|
const err = e as { stderr?: string; stdout?: string };
|
|
assert.fail(`effects harness failed:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("named ops: wire bytes through the real dispatch cycle", { skip: !hasZig, timeout: 300_000 }, () => {
|
|
const result = transpile(coreNamedOps);
|
|
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
|
|
assert.equal(result.ok, true, `transpile failed\n${result.typeErrors.join("\n")}\n${details}`);
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-effects-named-"));
|
|
try {
|
|
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
|
|
fs.writeFileSync(path.join(work, "core.zig"), result.zig!);
|
|
fs.writeFileSync(path.join(work, "harness.zig"), harnessNamedOps);
|
|
try {
|
|
execFileSync("zig", ["test", "harness.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
|
|
} catch (e) {
|
|
const err = e as { stderr?: string; stdout?: string };
|
|
assert.fail(`named-ops harness failed:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------- v2
|
|
|
|
// The v2 surface end to end: init command from initialModel's boot pair,
|
|
// bytes/record host payloads, keyed routed requests, cancel, and the
|
|
// declarative subscription channel — each asserted against the exact wire
|
|
// layout rt.zig documents, through the real dispatch cycle.
|
|
const coreV2 = `
|
|
import { Cmd, Sub, asciiBytes } from "@native-sdk/core";
|
|
|
|
export interface Model { readonly running: boolean; readonly data: Uint8Array; readonly errs: number; }
|
|
|
|
export type Msg =
|
|
| { readonly kind: "toggle" }
|
|
| { readonly kind: "save"; readonly gain: number }
|
|
| { readonly kind: "reload" }
|
|
| { readonly kind: "abort" }
|
|
| { readonly kind: "loaded"; readonly body: Uint8Array }
|
|
| { readonly kind: "failed"; readonly why: Uint8Array }
|
|
| { readonly kind: "tick"; readonly at: number };
|
|
|
|
export function initialModel(): [Model, Cmd<Msg>] {
|
|
return [
|
|
{ running: true, data: new Uint8Array(0), errs: 0 },
|
|
Cmd.request("store.read", asciiBytes("boot.bin"), { key: "boot", ok: "loaded", err: "failed" }),
|
|
];
|
|
}
|
|
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "toggle": return { ...model, running: !model.running };
|
|
case "save": return [model, Cmd.host("cfg.save", { gain: msg.gain, on: model.running, tag: asciiBytes("v2") })];
|
|
case "reload": return [model, Cmd.request("store.read", model.data, { key: "load", ok: "loaded", err: "failed" })];
|
|
case "abort": return [model, Cmd.batch([Cmd.cancel("load"), Cmd.host("blob.put", asciiBytes("bye"))])];
|
|
case "loaded": return { ...model, data: msg.body };
|
|
case "failed": return { ...model, errs: model.errs + 1 };
|
|
case "tick": return model;
|
|
}
|
|
}
|
|
|
|
export function subscriptions(model: Model): Sub<Msg> {
|
|
if (!model.running) return Sub.none;
|
|
return Sub.timer("tick", 250, "tick");
|
|
}
|
|
`;
|
|
|
|
const harnessV2 = `
|
|
const std = @import("std");
|
|
const core = @import("core.zig");
|
|
const rt = core.rt;
|
|
|
|
var g_model: *const core.Model = undefined;
|
|
|
|
fn dispatch(msg: core.Msg, log: []u8) []const u8 {
|
|
const r = core.update(g_model, msg);
|
|
g_model = core.commitModelRoot(r.model);
|
|
@memcpy(log[0..r.cmd.len], r.cmd);
|
|
const out = log[0..r.cmd.len];
|
|
rt.frameReset();
|
|
return out;
|
|
}
|
|
|
|
fn expectRecord(bytes: []const u8, expected: []const u8) !void {
|
|
try std.testing.expectEqualSlices(u8, expected, bytes);
|
|
}
|
|
|
|
test "v2 wire: init command, payloads, routing, cancel, subscriptions" {
|
|
try std.testing.expectEqual(@as(u32, 2), rt.cmd_format_version);
|
|
|
|
var log: [512]u8 = undefined;
|
|
|
|
// Boot: initialModel returns the pair-result; the init command is a
|
|
// keyed routed request per the documented request layout.
|
|
rt.resetAll();
|
|
const boot = core.initialModel();
|
|
g_model = core.commitModelRoot(boot.model);
|
|
var init_cmd: [512]u8 = undefined;
|
|
@memcpy(init_cmd[0..boot.cmd.len], boot.cmd);
|
|
const init_bytes = init_cmd[0..boot.cmd.len];
|
|
rt.frameReset();
|
|
const loaded_tag: u8 = @intFromEnum(std.meta.Tag(core.Msg).loaded);
|
|
const failed_tag: u8 = @intFromEnum(std.meta.Tag(core.Msg).failed);
|
|
var expect_init: std.ArrayList(u8) = .empty;
|
|
defer expect_init.deinit(std.testing.allocator);
|
|
const a = std.testing.allocator;
|
|
try expect_init.append(a, 0x05); // request
|
|
try expect_init.append(a, 10);
|
|
try expect_init.appendSlice(a, "store.read");
|
|
try expect_init.append(a, 4);
|
|
try expect_init.appendSlice(a, "boot");
|
|
try expect_init.append(a, loaded_tag);
|
|
try expect_init.append(a, failed_tag);
|
|
try expect_init.appendSlice(a, &.{ 8, 0, 0, 0 });
|
|
try expect_init.appendSlice(a, "boot.bin");
|
|
try expectRecord(init_bytes, expect_init.items);
|
|
|
|
// The routed result comes back as an ordinary Msg (host builds the ok
|
|
// arm with the result bytes); the model absorbs it with no effects.
|
|
const done = dispatch(.{ .loaded = "hello" }, &log);
|
|
try std.testing.expectEqual(@as(usize, 0), done.len);
|
|
try std.testing.expectEqualSlices(u8, "hello", g_model.data);
|
|
|
|
// host record payload: fields sorted by TS name (gain, on, tag) —
|
|
// f64 LE, bool byte, u32-length-prefixed bytes.
|
|
const save = dispatch(.{ .save = 1.5 }, &log);
|
|
var expect_save: std.ArrayList(u8) = .empty;
|
|
defer expect_save.deinit(a);
|
|
try expect_save.append(a, 0x04); // host_bytes
|
|
try expect_save.append(a, 8);
|
|
try expect_save.appendSlice(a, "cfg.save");
|
|
try expect_save.appendSlice(a, &.{ 15, 0, 0, 0 }); // 8 + 1 + 4 + 2
|
|
var gain_le: [8]u8 = undefined;
|
|
std.mem.writeInt(u64, &gain_le, @bitCast(@as(f64, 1.5)), .little);
|
|
try expect_save.appendSlice(a, &gain_le);
|
|
try expect_save.append(a, 1); // on = running = true
|
|
try expect_save.appendSlice(a, &.{ 2, 0, 0, 0 });
|
|
try expect_save.appendSlice(a, "v2");
|
|
try expectRecord(save, expect_save.items);
|
|
|
|
// The error arm routes too, as a plain Msg.
|
|
const failed = dispatch(.{ .failed = "nope" }, &log);
|
|
try std.testing.expectEqual(@as(usize, 0), failed.len);
|
|
try std.testing.expectEqual(@as(i64, 1), g_model.errs);
|
|
|
|
// batch = concatenation: cancel then a bytes-payload host command.
|
|
const abort = dispatch(.abort, &log);
|
|
var expect_abort: std.ArrayList(u8) = .empty;
|
|
defer expect_abort.deinit(a);
|
|
try expect_abort.append(a, 0x06); // cancel
|
|
try expect_abort.append(a, 4);
|
|
try expect_abort.appendSlice(a, "load");
|
|
try expect_abort.append(a, 0x04); // host_bytes
|
|
try expect_abort.append(a, 8);
|
|
try expect_abort.appendSlice(a, "blob.put");
|
|
try expect_abort.appendSlice(a, &.{ 3, 0, 0, 0 });
|
|
try expect_abort.appendSlice(a, "bye");
|
|
try expectRecord(abort, expect_abort.items);
|
|
|
|
// Subscriptions: descriptors derived from the committed model, timer
|
|
// record per the documented layout; Sub.none is the empty slice.
|
|
const tick_tag: u8 = @intFromEnum(std.meta.Tag(core.Msg).tick);
|
|
const subs = core.subscriptions(g_model);
|
|
var expect_subs: std.ArrayList(u8) = .empty;
|
|
defer expect_subs.deinit(a);
|
|
try expect_subs.append(a, 0x01); // timer
|
|
try expect_subs.append(a, 4);
|
|
try expect_subs.appendSlice(a, "tick");
|
|
var every_le: [8]u8 = undefined;
|
|
std.mem.writeInt(u64, &every_le, @bitCast(@as(f64, 250)), .little);
|
|
try expect_subs.appendSlice(a, &every_le);
|
|
try expect_subs.append(a, tick_tag);
|
|
try expectRecord(subs, expect_subs.items);
|
|
rt.frameReset();
|
|
|
|
_ = dispatch(.toggle, &log);
|
|
const paused = core.subscriptions(g_model);
|
|
try std.testing.expectEqual(@as(usize, 0), paused.len);
|
|
rt.frameReset();
|
|
}
|
|
`;
|
|
|
|
// ------------------------------------------------------------- named ops
|
|
|
|
// The slice-C surface: named engine ops (readFile/writeFile/fetch/
|
|
// clipboardWrite/clipboardRead) and the one-shot delay, asserted against
|
|
// the exact additive wire records rt.zig documents.
|
|
const coreNamedOps = `
|
|
import { Cmd, asciiBytes } from "@native-sdk/core";
|
|
|
|
export interface Model { readonly code: number; readonly data: Uint8Array; readonly saved: boolean; readonly errs: number; readonly firedAt: number; }
|
|
|
|
export type Msg =
|
|
| { readonly kind: "boot" }
|
|
| { readonly kind: "save" }
|
|
| { readonly kind: "get" }
|
|
| { readonly kind: "share" }
|
|
| { readonly kind: "paste" }
|
|
| { readonly kind: "later" }
|
|
| { readonly kind: "halt" }
|
|
| { readonly kind: "loaded"; readonly body: Uint8Array }
|
|
| { readonly kind: "wrote" }
|
|
| { readonly kind: "fetched"; readonly status: number; readonly body: Uint8Array }
|
|
| { readonly kind: "fired"; readonly at: number }
|
|
| { readonly kind: "failed"; readonly why: Uint8Array };
|
|
|
|
export function initialModel(): Model {
|
|
return { code: -1, data: new Uint8Array(0), saved: false, errs: 0, firedAt: -1 };
|
|
}
|
|
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "boot": return [model, Cmd.readFile(asciiBytes("store.bin"), { key: "load", ok: "loaded", err: "failed" })];
|
|
case "save": return [model, Cmd.writeFile(asciiBytes("store.bin"), model.data, { key: "save", ok: "wrote", err: "failed" })];
|
|
case "get": return [model, Cmd.fetch({ url: asciiBytes("https://x.test/q"), method: "POST", headers: { "x-tag": "7", authorization: model.data, accept: "text/plain" }, body: model.data, timeoutMs: 5000 }, { key: "get", ok: "fetched", err: "failed" })];
|
|
case "share": return [model, Cmd.clipboardWrite(model.data)];
|
|
case "paste": return [model, Cmd.clipboardRead({ ok: "loaded", err: "failed" })];
|
|
case "later": return [model, Cmd.delay("boom", 250, "fired")];
|
|
case "halt": return [model, Cmd.cancel("boom")];
|
|
case "loaded": return { ...model, data: msg.body };
|
|
case "wrote": return { ...model, saved: true };
|
|
case "fetched": return { ...model, code: msg.status, data: msg.body };
|
|
case "fired": return { ...model, firedAt: msg.at };
|
|
case "failed": return { ...model, errs: model.errs + 1 };
|
|
}
|
|
}
|
|
`;
|
|
|
|
const harnessNamedOps = `
|
|
const std = @import("std");
|
|
const core = @import("core.zig");
|
|
const rt = core.rt;
|
|
|
|
var g_model: *const core.Model = undefined;
|
|
|
|
fn dispatch(msg: core.Msg, log: []u8) []const u8 {
|
|
const r = core.update(g_model, msg);
|
|
g_model = core.commitModelRoot(r.model);
|
|
@memcpy(log[0..r.cmd.len], r.cmd);
|
|
const out = log[0..r.cmd.len];
|
|
rt.frameReset();
|
|
return out;
|
|
}
|
|
|
|
fn appendRoutedHead(list: *std.ArrayList(u8), a: std.mem.Allocator, op: u8, key: []const u8, ok: u8, err: u8) !void {
|
|
try list.append(a, op);
|
|
try list.append(a, @intCast(key.len));
|
|
try list.appendSlice(a, key);
|
|
try list.append(a, ok);
|
|
try list.append(a, err);
|
|
}
|
|
|
|
fn appendLong(list: *std.ArrayList(u8), a: std.mem.Allocator, bytes: []const u8) !void {
|
|
var len_le: [4]u8 = undefined;
|
|
std.mem.writeInt(u32, &len_le, @intCast(bytes.len), .little);
|
|
try list.appendSlice(a, &len_le);
|
|
try list.appendSlice(a, bytes);
|
|
}
|
|
|
|
test "named-op wire records match the documented additive layout" {
|
|
try std.testing.expectEqual(@as(u32, 2), rt.cmd_format_version);
|
|
const a = std.testing.allocator;
|
|
var log: [512]u8 = undefined;
|
|
|
|
rt.resetAll();
|
|
g_model = core.commitModelRoot(core.initialModel());
|
|
rt.frameReset();
|
|
|
|
const loaded: u8 = @intFromEnum(std.meta.Tag(core.Msg).loaded);
|
|
const wrote: u8 = @intFromEnum(std.meta.Tag(core.Msg).wrote);
|
|
const fetched: u8 = @intFromEnum(std.meta.Tag(core.Msg).fetched);
|
|
const fired: u8 = @intFromEnum(std.meta.Tag(core.Msg).fired);
|
|
const failed: u8 = @intFromEnum(std.meta.Tag(core.Msg).failed);
|
|
|
|
// read_file [0x07][key][ok][err][path u32-len].
|
|
var expect: std.ArrayList(u8) = .empty;
|
|
defer expect.deinit(a);
|
|
try appendRoutedHead(&expect, a, 0x07, "load", loaded, failed);
|
|
try appendLong(&expect, a, "store.bin");
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.boot, &log));
|
|
|
|
// Absorb bytes so save/get carry a real payload.
|
|
_ = dispatch(.{ .loaded = "abc" }, &log);
|
|
|
|
// write_file [0x08][key][ok][err][path u32-len][bytes u32-len].
|
|
expect.clearRetainingCapacity();
|
|
try appendRoutedHead(&expect, a, 0x08, "save", wrote, failed);
|
|
try appendLong(&expect, a, "store.bin");
|
|
try appendLong(&expect, a, "abc");
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.save, &log));
|
|
|
|
// The payload-less ok arm flows back as a plain void Msg.
|
|
_ = dispatch(.wrote, &log);
|
|
try std.testing.expect(g_model.saved);
|
|
|
|
// fetch [0x09][key][ok][err][method][timeout u32][url u32-len]
|
|
// [count]([name u8-len][value u32-len])*[body u32-len] —
|
|
// headers in TS-field-name sort order (accept < authorization
|
|
// < x-tag). The authorization VALUE is runtime bytes
|
|
// (model.data, "abc" here): it rides the same length-prefixed
|
|
// value field a literal does — no layout change.
|
|
expect.clearRetainingCapacity();
|
|
try appendRoutedHead(&expect, a, 0x09, "get", fetched, failed);
|
|
try expect.append(a, 1); // POST
|
|
try expect.appendSlice(a, &.{ 0x88, 0x13, 0, 0 }); // 5000 LE
|
|
try appendLong(&expect, a, "https://x.test/q");
|
|
try expect.append(a, 3);
|
|
try expect.append(a, 6);
|
|
try expect.appendSlice(a, "accept");
|
|
try appendLong(&expect, a, "text/plain");
|
|
try expect.append(a, 13);
|
|
try expect.appendSlice(a, "authorization");
|
|
try appendLong(&expect, a, "abc");
|
|
try expect.append(a, 5);
|
|
try expect.appendSlice(a, "x-tag");
|
|
try appendLong(&expect, a, "7");
|
|
try appendLong(&expect, a, "abc");
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.get, &log));
|
|
|
|
// The two-field ok record: number field takes the status, bytes
|
|
// field the body.
|
|
_ = dispatch(.{ .fetched = .{ .status = 404, .body = "nope" } }, &log);
|
|
try std.testing.expectEqual(@as(f64, 404), g_model.code);
|
|
try std.testing.expectEqualSlices(u8, "nope", g_model.data);
|
|
|
|
// clip_write [0x0A][bytes u32-len].
|
|
expect.clearRetainingCapacity();
|
|
try expect.append(a, 0x0A);
|
|
try appendLong(&expect, a, "nope");
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.share, &log));
|
|
|
|
// clip_read [0x0B][key(empty)][ok][err].
|
|
expect.clearRetainingCapacity();
|
|
try appendRoutedHead(&expect, a, 0x0B, "", loaded, failed);
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.paste, &log));
|
|
|
|
// delay [0x0C][key][after_ms f64 LE][tag].
|
|
expect.clearRetainingCapacity();
|
|
try expect.append(a, 0x0C);
|
|
try expect.append(a, 4);
|
|
try expect.appendSlice(a, "boom");
|
|
var ms_le: [8]u8 = undefined;
|
|
std.mem.writeInt(u64, &ms_le, @bitCast(@as(f64, 250)), .little);
|
|
try expect.appendSlice(a, &ms_le);
|
|
try expect.append(a, fired);
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.later, &log));
|
|
|
|
// cancel reuses the existing 0x06 record unchanged.
|
|
expect.clearRetainingCapacity();
|
|
try expect.append(a, 0x06);
|
|
try expect.append(a, 4);
|
|
try expect.appendSlice(a, "boom");
|
|
try std.testing.expectEqualSlices(u8, expect.items, dispatch(.halt, &log));
|
|
|
|
// The delay arm is the same number-payload shape timers use.
|
|
_ = dispatch(.{ .fired = 250 }, &log);
|
|
try std.testing.expectEqual(@as(f64, 250), g_model.firedAt);
|
|
}
|
|
`;
|
|
|
|
test("v2 effects: wire bytes through the real dispatch cycle", { skip: !hasZig, timeout: 300_000 }, () => {
|
|
const result = transpile(coreV2);
|
|
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
|
|
assert.equal(result.ok, true, `transpile failed\n${result.typeErrors.join("\n")}\n${details}`);
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-effects-v2-"));
|
|
try {
|
|
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
|
|
fs.writeFileSync(path.join(work, "core.zig"), result.zig!);
|
|
fs.writeFileSync(path.join(work, "harness.zig"), harnessV2);
|
|
try {
|
|
execFileSync("zig", ["test", "harness.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
|
|
} catch (e) {
|
|
const err = e as { stderr?: string; stdout?: string };
|
|
assert.fail(`v2 effects harness failed:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ------------------------------------------------------------------ streams
|
|
|
|
// The streaming ops end to end: spawn line/collect records (argv elements,
|
|
// stdin, the no-line sentinel) and the audio play/ctl records — each
|
|
// asserted against the exact wire layout rt.zig documents, through the real
|
|
// dispatch cycle.
|
|
const coreStreams = `
|
|
import { Cmd, asciiBytes } from "@native-sdk/core";
|
|
|
|
export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
|
|
|
|
export interface Model { readonly lines: number; readonly out: Uint8Array; readonly code: number; readonly errs: number; readonly pos: number; }
|
|
|
|
export type Msg =
|
|
| { readonly kind: "sample" }
|
|
| { readonly kind: "copy" }
|
|
| { readonly kind: "halt" }
|
|
| { readonly kind: "play" }
|
|
| { readonly kind: "stream" }
|
|
| { readonly kind: "drive" }
|
|
| { readonly kind: "line"; readonly text: Uint8Array }
|
|
| { readonly kind: "done"; readonly code: number }
|
|
| { readonly kind: "sampled"; readonly code: number; readonly output: Uint8Array }
|
|
| { readonly kind: "audio_evt"; readonly state: AudioState; readonly positionMs: number; readonly durationMs: number; readonly playing: boolean; readonly buffering: boolean; readonly bands: Uint8Array }
|
|
| { readonly kind: "failed"; readonly why: Uint8Array };
|
|
|
|
export function initialModel(): Model {
|
|
return { lines: 0, out: new Uint8Array(0), code: -1, errs: 0, pos: 0 };
|
|
}
|
|
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "sample": return [model, Cmd.spawn([asciiBytes("/bin/ps"), asciiBytes("-axo")], { key: "ps", line: "line", exit: "done", err: "failed" })];
|
|
case "copy": return [model, Cmd.spawn([asciiBytes("/usr/bin/pbcopy")], { key: "copy", stdin: model.out, collect: true, exit: "sampled", err: "failed" })];
|
|
case "halt": return [model, Cmd.cancel("ps")];
|
|
case "play": return [model, Cmd.audioPlay("track", { path: asciiBytes("a.mp3") }, { event: "audio_evt" })];
|
|
case "stream": return [model, Cmd.audioPlay("track", { url: asciiBytes("https://c.test/a.mp3"), cachePath: asciiBytes("cache/a"), expectedBytes: 4096 }, { event: "audio_evt" })];
|
|
case "drive": return [model, Cmd.batch([Cmd.audioPause("track"), Cmd.audioSeek("track", 45000), Cmd.audioSetVolume("track", 0.25), Cmd.audioStop("track")])];
|
|
case "line": return { ...model, lines: model.lines + 1, out: msg.text };
|
|
case "done": return { ...model, code: msg.code };
|
|
case "sampled": return { ...model, code: msg.code, out: msg.output };
|
|
case "audio_evt": return { ...model, pos: msg.positionMs };
|
|
case "failed": return { ...model, errs: model.errs + 1 };
|
|
}
|
|
}
|
|
`;
|
|
|
|
const harnessStreams = `
|
|
const std = @import("std");
|
|
const core = @import("core.zig");
|
|
const rt = core.rt;
|
|
|
|
var g_model: *const core.Model = undefined;
|
|
|
|
fn dispatch(msg: core.Msg, log: *std.ArrayList(u8)) []const u8 {
|
|
const r = core.update(g_model, msg);
|
|
g_model = core.commitModelRoot(r.model);
|
|
const start = log.items.len;
|
|
log.appendSlice(std.testing.allocator, r.cmd) catch @panic("oom");
|
|
rt.frameReset();
|
|
return log.items[start..];
|
|
}
|
|
|
|
fn tagOf(comptime arm: []const u8) u8 {
|
|
return @intFromEnum(@field(std.meta.Tag(core.Msg), arm));
|
|
}
|
|
|
|
fn expectLong(bytes: []const u8, at: *usize, expected: []const u8) !void {
|
|
const len = std.mem.readInt(u32, bytes[at.*..][0..4], .little);
|
|
try std.testing.expectEqual(@as(u32, @intCast(expected.len)), len);
|
|
try std.testing.expectEqualStrings(expected, bytes[at.* + 4 ..][0..expected.len]);
|
|
at.* += 4 + expected.len;
|
|
}
|
|
|
|
test "spawn and audio wire records match rt.zig's documented layout" {
|
|
var log: std.ArrayList(u8) = .empty;
|
|
defer log.deinit(std.testing.allocator);
|
|
|
|
rt.resetAll();
|
|
g_model = core.commitModelRoot(core.initialModel());
|
|
rt.frameReset();
|
|
|
|
// spawn, line mode: [0x0D][key][line][exit][err][mode 0][argc]
|
|
// [arg len u32 LE][arg]* [stdin len u32 LE][stdin].
|
|
const sample = dispatch(.sample, &log);
|
|
try std.testing.expectEqual(@as(u8, 0x0D), sample[0]);
|
|
try std.testing.expectEqual(@as(u8, 2), sample[1]);
|
|
try std.testing.expectEqualStrings("ps", sample[2..4]);
|
|
try std.testing.expectEqual(tagOf("line"), sample[4]);
|
|
try std.testing.expectEqual(tagOf("done"), sample[5]);
|
|
try std.testing.expectEqual(tagOf("failed"), sample[6]);
|
|
try std.testing.expectEqual(@as(u8, 0), sample[7]); // lines
|
|
try std.testing.expectEqual(@as(u8, 2), sample[8]); // argc
|
|
var at: usize = 9;
|
|
try expectLong(sample, &at, "/bin/ps");
|
|
try expectLong(sample, &at, "-axo");
|
|
try expectLong(sample, &at, ""); // no stdin
|
|
try std.testing.expectEqual(sample.len, at);
|
|
|
|
// Feed the stream's arms as plain Msgs: lines repeat, the exit code
|
|
// lands in the single-number arm.
|
|
_ = dispatch(.{ .line = "cpu 40" }, &log);
|
|
_ = dispatch(.{ .line = "cpu 7" }, &log);
|
|
_ = dispatch(.{ .done = 0 }, &log);
|
|
try std.testing.expectEqual(@as(@TypeOf(g_model.lines), 2), g_model.lines);
|
|
try std.testing.expectEqual(@as(@TypeOf(g_model.code), 0), g_model.code);
|
|
|
|
// spawn, collect mode: no line arm (0xFF sentinel), mode byte 1,
|
|
// the model's bytes as stdin.
|
|
const copy = dispatch(.copy, &log);
|
|
try std.testing.expectEqual(@as(u8, 0x0D), copy[0]);
|
|
try std.testing.expectEqual(@as(u8, 4), copy[1]);
|
|
try std.testing.expectEqualStrings("copy", copy[2..6]);
|
|
try std.testing.expectEqual(rt.spawn_no_line_tag, copy[6]);
|
|
try std.testing.expectEqual(tagOf("sampled"), copy[7]);
|
|
try std.testing.expectEqual(tagOf("failed"), copy[8]);
|
|
try std.testing.expectEqual(@as(u8, 1), copy[9]); // collect
|
|
try std.testing.expectEqual(@as(u8, 1), copy[10]); // argc
|
|
at = 11;
|
|
try expectLong(copy, &at, "/usr/bin/pbcopy");
|
|
try expectLong(copy, &at, "cpu 7"); // stdin = model.out
|
|
try std.testing.expectEqual(copy.len, at);
|
|
|
|
// The collect exit record arm takes { code, output } by type.
|
|
_ = dispatch(.{ .sampled = .{ .code = 1, .output = "PID CPU" } }, &log);
|
|
try std.testing.expectEqual(@as(@TypeOf(g_model.code), 1), g_model.code);
|
|
try std.testing.expectEqualStrings("PID CPU", g_model.out);
|
|
|
|
// cancel rides the shared record: [0x06][key_len][key].
|
|
const halt = dispatch(.halt, &log);
|
|
try std.testing.expectEqualSlices(u8, &[_]u8{ 0x06, 2, 'p', 's' }, halt);
|
|
|
|
// audio_play, local path: [0x0E][key][event_tag][path][url][cache]
|
|
// [expected f64 LE].
|
|
const play = dispatch(.play, &log);
|
|
try std.testing.expectEqual(@as(u8, 0x0E), play[0]);
|
|
try std.testing.expectEqual(@as(u8, 5), play[1]);
|
|
try std.testing.expectEqualStrings("track", play[2..7]);
|
|
try std.testing.expectEqual(tagOf("audio_evt"), play[7]);
|
|
at = 8;
|
|
try expectLong(play, &at, "a.mp3");
|
|
try expectLong(play, &at, "");
|
|
try expectLong(play, &at, "");
|
|
try std.testing.expectEqual(@as(f64, 0), @as(f64, @bitCast(std.mem.readInt(u64, play[at..][0..8], .little))));
|
|
try std.testing.expectEqual(play.len, at + 8);
|
|
|
|
// audio_play, streamed url with cache + expected size.
|
|
const stream = dispatch(.stream, &log);
|
|
at = 8;
|
|
try expectLong(stream, &at, "");
|
|
try expectLong(stream, &at, "https://c.test/a.mp3");
|
|
try expectLong(stream, &at, "cache/a");
|
|
try std.testing.expectEqual(@as(f64, 4096), @as(f64, @bitCast(std.mem.readInt(u64, stream[at..][0..8], .little))));
|
|
|
|
// The event arm round-trips as a plain Msg (the six-field record).
|
|
_ = dispatch(.{ .audio_evt = .{ .state = .position, .positionMs = 1500, .durationMs = 183000, .playing = true, .buffering = false, .bands = "" } }, &log);
|
|
try std.testing.expectEqual(@as(@TypeOf(g_model.pos), 1500), g_model.pos);
|
|
|
|
// audio_ctl batch: pause(0), seek(3, 45000), volume(4, 0.25),
|
|
// stop(2) — verb bytes in declaration order, values f64 LE.
|
|
const drive = dispatch(.drive, &log);
|
|
const ctl_len = 2 + 5 + 1 + 8; // op + key + verb + value
|
|
try std.testing.expectEqual(@as(usize, ctl_len * 4), drive.len);
|
|
const verbs = [_]u8{ 0, 3, 4, 2 };
|
|
const values = [_]f64{ 0, 45000, 0.25, 0 };
|
|
for (verbs, values, 0..) |verb, value, i| {
|
|
const record = drive[i * ctl_len ..][0..ctl_len];
|
|
try std.testing.expectEqual(@as(u8, 0x0F), record[0]);
|
|
try std.testing.expectEqual(@as(u8, 5), record[1]);
|
|
try std.testing.expectEqualStrings("track", record[2..7]);
|
|
try std.testing.expectEqual(verb, record[7]);
|
|
try std.testing.expectEqual(value, @as(f64, @bitCast(std.mem.readInt(u64, record[8..16], .little))));
|
|
}
|
|
}
|
|
`;
|
|
|
|
test("streaming ops: wire bytes through the real dispatch cycle", { skip: !hasZig, timeout: 300_000 }, () => {
|
|
const result = transpile(coreStreams);
|
|
const details = result.diagnostics.map((d) => `${d.id} ${d.message}`).join("\n");
|
|
assert.equal(result.ok, true, `transpile failed\n${result.typeErrors.join("\n")}\n${details}`);
|
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), "native-core-effects-streams-"));
|
|
try {
|
|
fs.copyFileSync(path.join(pkg, "rt", "rt.zig"), path.join(work, "rt.zig"));
|
|
fs.writeFileSync(path.join(work, "core.zig"), result.zig!);
|
|
fs.writeFileSync(path.join(work, "harness.zig"), harnessStreams);
|
|
try {
|
|
execFileSync("zig", ["test", "harness.zig"], { cwd: work, encoding: "utf8", stdio: "pipe" });
|
|
} catch (e) {
|
|
const err = e as { stderr?: string; stdout?: string };
|
|
assert.fail(`streaming-ops harness failed:\n${err.stderr ?? ""}${err.stdout ?? ""}`);
|
|
}
|
|
} finally {
|
|
fs.rmSync(work, { recursive: true, force: true });
|
|
}
|
|
});
|