1709 lines
68 KiB
TypeScript
1709 lines
68 KiB
TypeScript
// Emitter tests: one mapping rule per case — small TS in, the rule's Zig
|
|
// shape out. Substring asserts, not golden files: the shapes are the spec.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { emit, transpile } from "./helpers.ts";
|
|
|
|
test("R1 exported const number folds to pub const i64", () => {
|
|
const zig = emit(`export const MAX_TASKS = 64;\nexport function f(n: number): number { return n & MAX_TASKS; }`);
|
|
assert.match(zig, /pub const MAX_TASKS: i64 = 64;/);
|
|
});
|
|
|
|
test("R2 integer demand emits i64; float taint emits f64", () => {
|
|
const zig = emit(`
|
|
export function pick(bytes: Uint8Array, i: number): number {
|
|
return bytes[i];
|
|
}
|
|
export function scale(x: number): number {
|
|
return x * 0.5;
|
|
}
|
|
`);
|
|
assert.match(zig, /fn pick\(bytes: \[\]const u8, i: i64\) i64/);
|
|
assert.match(zig, /fn scale\(x: f64\) f64/);
|
|
});
|
|
|
|
test("R2 .length widens through jlen", () => {
|
|
const zig = emit(`export function len(bytes: Uint8Array): number { return bytes.length; }`);
|
|
assert.match(zig, /return jlen\(bytes\);/);
|
|
});
|
|
|
|
test("R3a byte-element stores narrow with JS ToUint8 semantics; in-range literals stay bare", () => {
|
|
const zig = emit(`
|
|
export function stamp(src: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(src.length);
|
|
for (let i = 0; i < src.length; i += 1) {
|
|
out[i] = src[i];
|
|
}
|
|
out[0] = 10;
|
|
return out;
|
|
}
|
|
`);
|
|
// A computed i64 value wraps modulo 256 exactly like node's Uint8Array store.
|
|
assert.match(zig, /= @as\(u8, @truncate\(@as\(u64, @bitCast\(/);
|
|
// Comptime-known in-range literals coerce on their own, unchanged.
|
|
assert.match(zig, /out\[0\] = 10;/);
|
|
});
|
|
|
|
test("R3 Uint8Array alias becomes []const u8", () => {
|
|
const zig = emit(`export type Bytes = Uint8Array;\nexport function id(b: Bytes): Bytes { return b; }`);
|
|
assert.match(zig, /pub const Bytes = \[\]const u8;/);
|
|
});
|
|
|
|
test("R3b SDK asciiBytes intrinsic folds a literal to rodata (recognized by identity, renames honored)", () => {
|
|
const zig = emit(`
|
|
import { asciiBytes } from "@native-sdk/core";
|
|
export function greeting(): Uint8Array { return asciiBytes("hello"); }
|
|
`);
|
|
assert.match(zig, /return "hello";/);
|
|
assert.ok(!zig.includes("fn asciiBytes"), "the intrinsic body is never emitted");
|
|
|
|
const renamed = emit(`
|
|
import { asciiBytes as ab } from "@native-sdk/core";
|
|
export function greeting(): Uint8Array { return ab("hey"); }
|
|
`);
|
|
assert.match(renamed, /return "hey";/);
|
|
});
|
|
|
|
test("R3b a local function named asciiBytes shadows the intrinsic, not the reverse", () => {
|
|
// A module-local fn(string): Uint8Array is no longer a blessed second
|
|
// path: its body observes string code units and teaches NS1004.
|
|
const result = transpile(`
|
|
export function asciiBytes(s: string): Uint8Array {
|
|
const out = new Uint8Array(s.length);
|
|
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
|
|
return out;
|
|
}
|
|
export function greeting(): Uint8Array { return asciiBytes("hello"); }
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1004"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R4 record promotion: helper record by value, model record behind *const", () => {
|
|
const zig = emit(`
|
|
interface Range { readonly start: number; readonly end: number; }
|
|
export interface Model { readonly cursor: number; }
|
|
export function width(r: Range): number { return r.end - r.start; }
|
|
export function bump(model: Model): Model { return { cursor: model.cursor + 1 }; }
|
|
`);
|
|
assert.match(zig, /fn width\(r: Range\) i64/);
|
|
assert.match(zig, /fn bump\(model: \*const Model\) \*const Model/);
|
|
assert.match(zig, /rt\.frameCreate\(Model, /);
|
|
});
|
|
|
|
test("R5 string-literal union becomes a wire-stable enum", () => {
|
|
const zig = emit(`
|
|
export type Filter = "all" | "active" | "done";
|
|
export function next(f: Filter): Filter { return f === "all" ? "active" : "done"; }
|
|
`);
|
|
assert.match(zig, /pub const Filter = enum\(u8\) \{ all = 0, active = 1, done = 2 \};/);
|
|
assert.match(zig, /f == \.all/);
|
|
});
|
|
|
|
test("R5 number-literal union keeps its integer repr", () => {
|
|
const zig = emit(`
|
|
type RunClass = 0 | 1 | 2;
|
|
export function cls(b: number): RunClass { return b === 32 ? 1 : 0; }
|
|
`);
|
|
assert.match(zig, /const RunClass = u8;/);
|
|
});
|
|
|
|
test("R5b equality across overlapping literal unions compares tag names", () => {
|
|
const zig = emit(`
|
|
export type Category = "food" | "travel" | "gear";
|
|
export type CategoryFilter = "all" | "food" | "travel" | "gear";
|
|
export function matches(category: Category, filter: CategoryFilter): boolean {
|
|
return filter === "all" || category === filter;
|
|
}
|
|
`);
|
|
assert.match(zig, /filter == \.all or tagEq\(category, filter\)/);
|
|
});
|
|
|
|
test("R5b assignment across overlapping literal unions re-tags the value", () => {
|
|
const zig = emit(`
|
|
export type Category = "food" | "travel" | "gear";
|
|
export type CategoryFilter = "all" | "food" | "travel" | "gear";
|
|
export function asFilter(category: Category): CategoryFilter {
|
|
return category;
|
|
}
|
|
`);
|
|
assert.match(zig, /return tagCast\(CategoryFilter, category\);/);
|
|
});
|
|
|
|
test("R2 demand propagates across the assignment into the model field", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly durationSeconds: number; }
|
|
export type Msg =
|
|
| { readonly kind: "set_duration"; readonly seconds: number }
|
|
| { readonly kind: "noop" };
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) {
|
|
case "set_duration": {
|
|
if (msg.seconds <= 0) return model;
|
|
return { durationSeconds: msg.seconds };
|
|
}
|
|
case "noop": return model;
|
|
}
|
|
}
|
|
`);
|
|
assert.match(zig, /durationSeconds: i64/);
|
|
});
|
|
|
|
test("R2 integer reads widen exactly inside float sites", () => {
|
|
const zig = emit(`export function half(bytes: Uint8Array): number { return bytes.length * 0.5; }`);
|
|
assert.match(zig, /@as\(f64, @floatFromInt\(jlen\(bytes\)\)\) \* 0\.5/);
|
|
});
|
|
|
|
test("R13c inline kind guard narrows payload access to the arm accessor", () => {
|
|
const zig = emit(`
|
|
export type Msg = { readonly kind: "add"; readonly n: number } | { readonly kind: "zero" };
|
|
export function tally(msg: Msg, bytes: Uint8Array): number {
|
|
let acc = 0;
|
|
if (msg.kind === "add") acc += msg.n;
|
|
return bytes[acc];
|
|
}
|
|
`);
|
|
assert.match(zig, /if \(msg == \.add\) \{/);
|
|
assert.match(zig, /acc \+= msg\.add;/);
|
|
});
|
|
|
|
test("R6 discriminated union becomes union(enum) with inline payloads", () => {
|
|
const zig = emit(`
|
|
export type Msg =
|
|
| { readonly kind: "add" }
|
|
| { readonly kind: "toggle"; readonly id: number }
|
|
| { readonly kind: "move"; readonly dx: number; readonly dy: number };
|
|
export interface Model { readonly n: number; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) {
|
|
case "add": return { n: model.n + 1 };
|
|
case "toggle": return { n: msg.id };
|
|
case "move": return { n: msg.dx + msg.dy };
|
|
}
|
|
}
|
|
export function at(model: Model, bytes: Uint8Array): number {
|
|
return bytes[model.n];
|
|
}
|
|
`);
|
|
assert.match(zig, /pub const Msg = union\(enum\) \{/);
|
|
assert.match(zig, /toggle: i64,/);
|
|
assert.match(zig, /move: struct \{ dx: i64, dy: i64 \},/);
|
|
});
|
|
|
|
test("R7 T|null becomes ?T with orelse fusion", () => {
|
|
const zig = emit(`
|
|
export function find(xs: Uint8Array, want: number): number | null {
|
|
for (let i = 0; i < xs.length; i++) {
|
|
if (xs[i] === want) return i;
|
|
}
|
|
return null;
|
|
}
|
|
export function findOrZero(xs: Uint8Array, want: number): number {
|
|
const hit = find(xs, want);
|
|
if (hit === null) return 0;
|
|
return hit;
|
|
}
|
|
`);
|
|
assert.match(zig, /\?i64/);
|
|
assert.match(zig, /const hit = find\(xs, want\) orelse return 0;/);
|
|
});
|
|
|
|
test("R7 an early-exit guard whose narrowed value goes unread stays a plain null test", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly now: number | null; readonly nowLen: number; }
|
|
export function label(model: Model): number {
|
|
if (model.now === null) return -1;
|
|
return model.nowLen;
|
|
}
|
|
`);
|
|
// Capturing would bind an unused Zig const (a compile error), and the
|
|
// read test must not match \`model.nowLen\` inside \`model.now...\`.
|
|
assert.match(zig, /if \(model\.now == null\) return -1;/);
|
|
assert.doesNotMatch(zig, /const now = model\.now orelse/);
|
|
});
|
|
|
|
test("R7c the global undefined VALUE emits the optional empty, never Zig undefined", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; }
|
|
export function pick(tasks: readonly Task[], want: number): Task | undefined {
|
|
if (want < 0) return undefined;
|
|
return tasks.find((t) => t.id === want);
|
|
}
|
|
`);
|
|
assert.match(zig, /return null;/);
|
|
assert.doesNotMatch(zig, /return undefined;/);
|
|
});
|
|
|
|
test("R8 Math.min/max map to @min/@max", () => {
|
|
const zig = emit(`export function clamp(a: number, b: number): number { return Math.min(a & 255, Math.max(b & 255, 0)); }`);
|
|
assert.match(zig, /@min\(/);
|
|
assert.match(zig, /@max\(/);
|
|
});
|
|
|
|
test("R9 bitwise ops on proven ints stay integer ops", () => {
|
|
const zig = emit(`export function mask(b: number): number { return (b & 0xc0) === 0x80 ? 1 : 0; }`);
|
|
assert.match(zig, /b & 192/);
|
|
assert.match(zig, /fn mask\(b: i64\) i64/);
|
|
});
|
|
|
|
test("R9 bitwise without a range proof takes the ToInt32 wrap helpers", () => {
|
|
const zig = emit(`
|
|
export function band(a: number, b: number): number { return a & b; }
|
|
export function bor(a: number, b: number): number { return a | b; }
|
|
export function bxor(a: number, b: number): number { return a ^ b; }
|
|
`);
|
|
assert.match(zig, /return jsAnd\(a, b\);/);
|
|
assert.match(zig, /return jsOr\(a, b\);/);
|
|
assert.match(zig, /return jsXor\(a, b\);/);
|
|
});
|
|
|
|
test("optional numeric equality emits the null-safe plain operator, never an unwrap", () => {
|
|
const zig = emit(`
|
|
export function isZero(cls: number | null): boolean { return cls === 0; }
|
|
export function nonZero(cls: number | null): boolean { return cls !== 0; }
|
|
`);
|
|
assert.match(zig, /return cls == 0;/);
|
|
assert.match(zig, /return cls != 0;/);
|
|
assert.ok(!zig.includes(".?"), "no unwrap in optional comparisons");
|
|
});
|
|
|
|
test("division widens to IEEE f64 division; remainder maps to @rem", () => {
|
|
const zig = emit(`
|
|
export function halfLen(bytes: Uint8Array): number { return bytes.length / 2; }
|
|
export function wrapAt(bytes: Uint8Array, at: number): number { return bytes[at % bytes.length]; }
|
|
`);
|
|
assert.match(zig, /@as\(f64, @floatFromInt\(jlen\(bytes\)\)\) \/ 2/);
|
|
assert.match(zig, /@rem\(at, jlen\(bytes\)\)/);
|
|
});
|
|
|
|
test("R8 float Math.min/max take the NaN-propagating rt helpers; Math.round maps to rt.jsRound", () => {
|
|
const zig = emit(`
|
|
export function lo(a: number, b: number): number { return Math.min(a, b); }
|
|
export function nearest(x: number): number { return Math.round(x); }
|
|
`);
|
|
assert.match(zig, /return rt\.jsMin\(a, b\);/);
|
|
assert.match(zig, /return rt\.jsRound\(x\);/);
|
|
});
|
|
|
|
test("R10 reassigned let becomes var", () => {
|
|
const zig = emit(`
|
|
export function count(bytes: Uint8Array): number {
|
|
let n = 0;
|
|
while (n < bytes.length && bytes[n] === 32) n += 1;
|
|
return n;
|
|
}
|
|
`);
|
|
assert.match(zig, /var n: i64 = 0;/);
|
|
});
|
|
|
|
test("R11 fresh buffers: alloc, memcpy set, subarray view, slice copy", () => {
|
|
const zig = emit(`
|
|
export function build(src: Uint8Array): Uint8Array {
|
|
const out = new Uint8Array(src.length);
|
|
out.set(src.subarray(0, src.length), 0);
|
|
return out;
|
|
}
|
|
export function copyHalf(src: Uint8Array, mid: number): Uint8Array {
|
|
return src.slice(0, mid);
|
|
}
|
|
`);
|
|
assert.match(zig, /rt\.frameAlloc\(u8, uz\(jlen\(src\)\)\)/);
|
|
assert.match(zig, /@memcpy\(/);
|
|
// Bytes .slice resolves its bounds like array .slice: clamped, never a trap.
|
|
assert.match(zig, /const copy_from = rt\.sliceIndex\(src\.len, 0\);/);
|
|
assert.match(zig, /const copy_to = @max\(copy_from, rt\.sliceIndex\(src\.len, mid\)\);/);
|
|
assert.match(zig, /@memcpy\(copy, src\[copy_from\.\.copy_to\]\);/);
|
|
});
|
|
|
|
test("R12 if/else chain over an enum scrutinee compares tags", () => {
|
|
const zig = emit(`
|
|
export type Dir = "left" | "right";
|
|
export function step(d: Dir): number {
|
|
if (d === "left") return -1;
|
|
return 1;
|
|
}
|
|
`);
|
|
assert.match(zig, /d == \.left/);
|
|
});
|
|
|
|
test("R13 switch on kind with payload capture; R13b guard narrows", () => {
|
|
const zig = emit(`
|
|
export type Ev = { readonly kind: "insert"; readonly text: Uint8Array } | { readonly kind: "clear" };
|
|
export function size(ev: Ev): number {
|
|
switch (ev.kind) {
|
|
case "insert": return ev.text.length;
|
|
case "clear": return 0;
|
|
}
|
|
}
|
|
export function insertLen(ev: Ev): number | null {
|
|
if (ev.kind !== "insert") return null;
|
|
const insertion = ev.text;
|
|
return insertion.length;
|
|
}
|
|
`);
|
|
assert.match(zig, /switch \(ev\) \{/);
|
|
assert.match(zig, /\.insert => \|text\| return jlen\(text\),/);
|
|
assert.match(zig, /if \(ev != \.insert\) return null;/);
|
|
assert.match(zig, /ev\.insert/);
|
|
});
|
|
|
|
test("R15 object spread copies the node and overwrites fields", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly a: number; readonly b: number; }
|
|
export function setA(model: Model, a: number): Model {
|
|
return { ...model, a: a };
|
|
}
|
|
export function idx(model: Model, bytes: Uint8Array): number { return bytes[model.a]; }
|
|
`);
|
|
assert.match(zig, /const out = rt\.frameCreate\(Model, model\.\*\);/);
|
|
assert.match(zig, /out\.a = a;/);
|
|
assert.match(zig, /return out;/);
|
|
});
|
|
|
|
test("R16/R17 array of records is []const *const; spread/map/filter lower to loops", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export interface Model { readonly tasks: readonly Task[]; }
|
|
export function add(model: Model, t: Task): Model {
|
|
return { ...model, tasks: [...model.tasks, t] };
|
|
}
|
|
export function toggle(model: Model, id: number): Model {
|
|
return { ...model, tasks: model.tasks.map((t) => (t.id === id ? { ...t, done: !t.done } : t)) };
|
|
}
|
|
export function purge(model: Model): Model {
|
|
return { ...model, tasks: model.tasks.filter((t) => !t.done) };
|
|
}
|
|
`);
|
|
assert.match(zig, /tasks: \[\]const \*const Task/);
|
|
assert.match(zig, /rt\.frameAlloc\(\*const Task, model\.tasks\.len \+ 1\);/);
|
|
assert.match(zig, /for \(model\.tasks, 0\.\.\) \|t, i\| \{/);
|
|
assert.match(zig, /for \(model\.tasks\) \|t\| \{/);
|
|
assert.match(zig, /\[0\.\.tasks_len\]/);
|
|
});
|
|
|
|
test("R12b for...of over an array becomes a for-capture loop with break/continue", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export function firstOpenId(tasks: readonly Task[]): number {
|
|
let hit = -1;
|
|
for (const t of tasks) {
|
|
if (t.done) continue;
|
|
hit = t.id;
|
|
break;
|
|
}
|
|
return hit;
|
|
}
|
|
`);
|
|
assert.match(zig, /for \(tasks\) \|t\| \{/);
|
|
assert.match(zig, /continue;/);
|
|
assert.match(zig, /break;/);
|
|
});
|
|
|
|
test("R12b for...of over bytes captures the u8 and widens to the slot's class", () => {
|
|
const zig = emit(`
|
|
export function total(bytes: Uint8Array): number {
|
|
let n = 0;
|
|
for (const b of bytes) {
|
|
n += b;
|
|
}
|
|
return n;
|
|
}
|
|
export function scaled(bytes: Uint8Array): number {
|
|
let n = 0.0;
|
|
for (const b of bytes) {
|
|
n += b * 0.5;
|
|
}
|
|
return n;
|
|
}
|
|
`);
|
|
assert.match(zig, /for \(bytes\) \|b_byte\| \{/);
|
|
assert.match(zig, /const b: i64 = b_byte;/);
|
|
assert.match(zig, /const b: f64 = @as\(f64, @floatFromInt\(b_byte\)\);/);
|
|
});
|
|
|
|
test("R12d for...of over .entries() lowers the [i, x] pair onto the indexed loop", () => {
|
|
const zig = emit(`
|
|
export function sum(xs: readonly number[]): number {
|
|
let total = 0;
|
|
for (const [i, x] of xs.entries()) {
|
|
total += x + i;
|
|
}
|
|
return total;
|
|
}
|
|
`);
|
|
assert.match(zig, /for \(xs, 0\.\.\) \|x, i_idx\| \{/);
|
|
assert.match(zig, /const i: f64 = @as\(f64, @floatFromInt\(i_idx\)\);/);
|
|
});
|
|
|
|
test("R12d .entries() beyond the [i, x] pair form keeps a tailored teach", () => {
|
|
const result = transpile(`
|
|
export function sum(xs: readonly number[]): number {
|
|
let total = 0;
|
|
for (const [i] of xs.entries()) {
|
|
total += i;
|
|
}
|
|
return total;
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS9001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("[index, element]"), d.message);
|
|
});
|
|
|
|
test("R17d find lowers to an early-exit scan yielding the optional element", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export function firstDoneId(tasks: readonly Task[]): number {
|
|
const hit = tasks.find((t) => t.done);
|
|
if (hit === undefined) return -1;
|
|
return hit.id;
|
|
}
|
|
`);
|
|
assert.match(zig, /var found: \?Task = null;/);
|
|
assert.match(zig, /found = t;/);
|
|
// The `=== undefined` miss test fuses into the native orelse.
|
|
assert.match(zig, /const hit = found orelse return -1;/);
|
|
});
|
|
|
|
test("R17d findIndex/some/every carry the JS defaults (-1, false, true)", () => {
|
|
const zig = emit(`
|
|
export function at(xs: readonly number[], lim: number): number {
|
|
return xs.findIndex((x) => x > lim);
|
|
}
|
|
export function any(xs: readonly number[], lim: number): boolean {
|
|
return xs.some((x) => x > lim);
|
|
}
|
|
export function all(xs: readonly number[], lim: number): boolean {
|
|
return xs.every((x) => x > lim);
|
|
}
|
|
`);
|
|
assert.match(zig, /var found_at: i64 = -1;/);
|
|
assert.match(zig, /found_at = @intCast\(i\);/);
|
|
assert.match(zig, /var any_match = false;/);
|
|
assert.match(zig, /var all_match = true;/);
|
|
assert.match(zig, /if \(!\(x > lim\)\) \{/);
|
|
});
|
|
|
|
test("R17e reduce lowers to an accumulator loop under the callback's own name", () => {
|
|
const zig = emit(`
|
|
export function total(xs: readonly number[]): number {
|
|
return xs.reduce((sum, x) => sum + x, 0);
|
|
}
|
|
`);
|
|
assert.match(zig, /var sum: f64 = 0;/);
|
|
assert.match(zig, /for \(xs\) \|x\| \{/);
|
|
assert.match(zig, /sum = sum \+ x;/);
|
|
});
|
|
|
|
test("R17e reduce without an initial value is the taught NS1007 stop", () => {
|
|
const result = transpile(`
|
|
export function total(xs: readonly number[]): number {
|
|
return xs.reduce((sum, x) => sum + x);
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS1007");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("initial value"), d.message);
|
|
});
|
|
|
|
test("R17f array slice resolves both bounds through rt.sliceIndex and copies", () => {
|
|
const zig = emit(`
|
|
export function middle(xs: readonly number[], from: number, to: number): readonly number[] {
|
|
return xs.slice(from, to);
|
|
}
|
|
`);
|
|
assert.match(zig, /const copied_from = rt\.sliceIndex\(xs\.len, from\);/);
|
|
assert.match(zig, /const copied_to = @max\(copied_from, rt\.sliceIndex\(xs\.len, to\)\);/);
|
|
assert.match(zig, /rt\.frameAlloc\(f64, copied_to - copied_from\)/);
|
|
assert.match(zig, /@memcpy\(copied, xs\[copied_from\.\.copied_to\]\);/);
|
|
});
|
|
|
|
test("R17g concat is one exact-size alloc with a memcpy per part", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export function stitched(xs: readonly Task[], ys: readonly Task[]): readonly Task[] {
|
|
return xs.concat(ys);
|
|
}
|
|
`);
|
|
assert.match(zig, /rt\.frameAlloc\(Task, xs\.len \+ ys\.len\)/);
|
|
assert.match(zig, /@memcpy\(combined\[0\.\.xs\.len\], xs\);/);
|
|
assert.match(zig, /@memcpy\(combined\[xs\.len\.\.\], ys\);/);
|
|
});
|
|
|
|
test("R17h indexOf scans with strict equality; includes adds the SameValueZero NaN arm", () => {
|
|
const zig = emit(`
|
|
export function whereIs(xs: readonly number[], v: number): number {
|
|
return xs.indexOf(v);
|
|
}
|
|
export function has(xs: readonly number[], v: number): boolean {
|
|
return xs.includes(v);
|
|
}
|
|
`);
|
|
assert.match(zig, /var found_at: i64 = -1;/);
|
|
assert.match(zig, /if \(x == v\) \{/);
|
|
assert.match(zig, /if \(x == v or \(std\.math\.isNan\(x\) and std\.math\.isNan\(v\)\)\) \{/);
|
|
});
|
|
|
|
test("R17h indexOf on record elements is a taught stop (JS reference identity)", () => {
|
|
const result = transpile(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export function has(tasks: readonly Task[], t: Task): number {
|
|
return tasks.indexOf(t);
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R18b bytes join prints each byte through {d} with the folded separator", () => {
|
|
const zig = emit(`
|
|
export function csv(bytes: Uint8Array): string {
|
|
return bytes.join("-");
|
|
}
|
|
`);
|
|
assert.match(zig, /rt\.frameAlloc\(u8, if \(bytes\.len == 0\) 0 else bytes\.len \* 3 \+ \(bytes\.len - 1\) \* 1\)/);
|
|
assert.match(zig, /@memcpy\(joined\[joined_len\.\.\]\[0\.\.1\], "-"\);/);
|
|
assert.match(zig, /std\.fmt\.bufPrint\(joined\[joined_len\.\.\], "\{d\}", \.\{b\}\) catch unreachable;/);
|
|
assert.match(zig, /return joined\[0\.\.joined_len\];/);
|
|
});
|
|
|
|
test("R18b join on a number array is a taught stop (float elements)", () => {
|
|
const result = transpile(`
|
|
export function csv(xs: readonly number[]): string {
|
|
return xs.join(",");
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R7c empty tests must name the value's own empty (null vs undefined)", () => {
|
|
// `=== undefined` on a null-flavored value would be constant-false under
|
|
// node but a hit natively — taught, never emitted.
|
|
const wrongUndef = transpile(`
|
|
export function f(cursor: number | null): boolean {
|
|
return cursor === undefined;
|
|
}
|
|
`);
|
|
assert.equal(wrongUndef.ok, false);
|
|
assert.ok(wrongUndef.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(wrongUndef.diagnostics));
|
|
|
|
// `=== null` on a find result (an undefined-flavored miss) is the same
|
|
// divergence in the other direction.
|
|
const wrongNull = transpile(`
|
|
export function f(xs: readonly number[]): number {
|
|
const hit = xs.find((x) => x > 0);
|
|
if (hit === null) return -1;
|
|
return 0;
|
|
}
|
|
`);
|
|
assert.equal(wrongNull.ok, false);
|
|
assert.ok(wrongNull.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(wrongNull.diagnostics));
|
|
});
|
|
|
|
test("R7 identifier guards narrow through if-captures (locals and params)", () => {
|
|
const zig = emit(`
|
|
export interface Sel { readonly at: number; readonly len: number; }
|
|
export function width(sel: Sel | null): number {
|
|
if (sel !== null) {
|
|
const spread = sel.len - sel.at;
|
|
return spread * 2;
|
|
}
|
|
return 0;
|
|
}
|
|
`);
|
|
assert.match(zig, /if \(sel\) \|sel_2\| \{/);
|
|
assert.match(zig, /sel_2\.len - sel_2\.at/);
|
|
});
|
|
|
|
test("R18 template literal with an integer hole becomes bufPrint {d}", () => {
|
|
const zig = emit(`
|
|
import { asciiBytes } from "@native-sdk/core";
|
|
export function label(n: number): Uint8Array {
|
|
return asciiBytes(\`Task \${n & 255}\`);
|
|
}
|
|
`);
|
|
assert.match(zig, /std\.fmt\.bufPrint\(buf, "Task \{d\}"/);
|
|
});
|
|
|
|
test("R7b optional chains lower to null-propagating if-captures", () => {
|
|
const zig = emit(`
|
|
export interface Badge { readonly count: number; }
|
|
export interface Panel { readonly badge: Badge | null; }
|
|
export interface Model { readonly panel: Panel | null; }
|
|
export function badgeCount(model: Model): number {
|
|
return model.panel?.badge?.count ?? 0;
|
|
}
|
|
`);
|
|
assert.match(zig, /\(if \(model\.panel\) \|panel\| panel\.badge else null\)/);
|
|
assert.match(zig, /\|badge\| badge\.count else null\) orelse 0;/);
|
|
});
|
|
|
|
test("R7b a boolean chain in a condition folds its short-circuit to false", () => {
|
|
const zig = emit(`
|
|
export interface Flags { readonly urgent: boolean; }
|
|
export interface Model { readonly flags: Flags | null; }
|
|
export function gate(model: Model): number {
|
|
if (model.flags?.urgent) return 1;
|
|
return 0;
|
|
}
|
|
`);
|
|
assert.match(zig, /orelse false/);
|
|
});
|
|
|
|
test("NS1021 null test on a parenthesized chain is still taught, never emitted", () => {
|
|
const result = transpile(`
|
|
export interface Inner { readonly x: number | null; }
|
|
export interface Model { readonly inner: Inner | null; }
|
|
export function f(model: Model): boolean { return (model.inner?.x) === null; }
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1021"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R5c string equality lowers to strEq; inequality negates it", () => {
|
|
const zig = emit(`
|
|
export function commandMsg(name: string): number {
|
|
if (name === "app.add") return 1;
|
|
if (name !== "app.remove") return 2;
|
|
return 0;
|
|
}
|
|
`);
|
|
assert.match(zig, /if \(strEq\(name, "app\.add"\)\) return 1;/);
|
|
assert.match(zig, /if \(!strEq\(name, "app\.remove"\)\) return 2;/);
|
|
});
|
|
|
|
test("R5c literal-union value against a plain string compares the tag name", () => {
|
|
const zig = emit(`
|
|
export type Filter = "all" | "active" | "done";
|
|
export function matchesName(f: Filter, name: string): boolean {
|
|
return f === name;
|
|
}
|
|
`);
|
|
assert.match(zig, /strEq\(@tagName\(f\), name\)/);
|
|
});
|
|
|
|
test("`===` on bytes stops loudly (JS reference identity has no slice mapping)", () => {
|
|
const result = transpile(`export function same(a: Uint8Array, b: Uint8Array): boolean { return a === b; }`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("NS1020 Cmd.host argument smuggled past the types stops with the rule", () => {
|
|
const result = transpile(`
|
|
import { Cmd } from "@native-sdk/core";
|
|
export interface Model { readonly count: number; }
|
|
export type Msg = { readonly kind: "go"; readonly path: string } | { readonly kind: "noop" };
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "go": return [model, Cmd.host("open", msg.path as unknown as number)];
|
|
case "noop": return model;
|
|
}
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1020"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("NS1019 default parameter values are taught, never dropped", () => {
|
|
const result = transpile(`
|
|
function step(n: number, by: number = 1): number { return n + by; }
|
|
export function f(n: number): number { return step(n); }
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1019"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("NS1018 string + is taught, never emitted", () => {
|
|
const result = transpile(`export function f(s: string): string { return "hi " + s; }`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1018"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("commit walkers are generated from the Model shape", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly title: Uint8Array; readonly done: boolean; }
|
|
export interface Model { readonly tasks: readonly Task[]; readonly name: Uint8Array; }
|
|
export function idx(model: Model, bytes: Uint8Array): number { return bytes[model.tasks.length]; }
|
|
`);
|
|
assert.match(zig, /fn commitBytes\(bytes: \[\]const u8, mode: CommitMode\) \[\]const u8/);
|
|
assert.match(zig, /fn commitTask\(value: \*const Task, mode: CommitMode\) \*const Task/);
|
|
assert.match(zig, /fn commitTasks\(values: \[\]const \*const Task, mode: CommitMode\)/);
|
|
assert.match(zig, /fn commitModel\(value: \*const Model, mode: CommitMode\)/);
|
|
assert.match(zig, /pub fn commitModelRoot\(next: \*const Model\) \*const Model/);
|
|
});
|
|
|
|
test("Cmd pair-return lowers onto the rt command builders", () => {
|
|
const zig = emit(`
|
|
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: "quiet" };
|
|
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.batch([Cmd.now("tick"), Cmd.host("beep", model.count, 2)])];
|
|
case "tick": return { ...model, count: msg.at };
|
|
case "quiet": return [model, Cmd.none];
|
|
}
|
|
}
|
|
`);
|
|
// The pair-return contract wraps every return, sugaring bare models.
|
|
assert.match(zig, /pub const UpdateResult = struct \{ model: \*const Model, cmd: rt\.Cmd \};/);
|
|
assert.match(zig, /pub fn update\(model: \*const Model, msg: Msg\) UpdateResult/);
|
|
assert.match(zig, /\.cmd = rt\.cmd_none/);
|
|
// Factories lower onto the rt builders (the versioned effect-bytes channel).
|
|
assert.match(zig, /\.cmd = rt\.cmdPersist\(\)/);
|
|
assert.match(zig, /rt\.cmdNow\(@intFromEnum\(std\.meta\.Tag\(Msg\)\.tick\)\)/);
|
|
assert.match(zig, /rt\.cmdHost\("beep", &\.\{ model\.count, 2 \}\)/);
|
|
assert.match(zig, /rt\.cmdBatch\(&\.\{ rt\.cmdNow\(/);
|
|
});
|
|
|
|
test("Cmd v2 lowers bytes payloads, keyed requests, and cancel onto the rt builders", () => {
|
|
const zig = emit(`
|
|
import { Cmd, asciiBytes } from "@native-sdk/core";
|
|
export interface Model { readonly draft: Uint8Array; readonly errs: number; }
|
|
export type Msg =
|
|
| { readonly kind: "save" }
|
|
| { readonly kind: "load" }
|
|
| { readonly kind: "stop" }
|
|
| { readonly kind: "loaded"; readonly body: Uint8Array }
|
|
| { readonly kind: "failed"; readonly why: Uint8Array };
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "save": return [model, Cmd.host("store.write", model.draft)];
|
|
case "load": return [model, Cmd.request("store.read", asciiBytes("notes.json"), { key: "load", ok: "loaded", err: "failed" })];
|
|
case "stop": return [model, Cmd.cancel("load")];
|
|
case "loaded": return { draft: msg.body, errs: model.errs };
|
|
case "failed": return { ...model, errs: model.errs + 1 };
|
|
}
|
|
}
|
|
`);
|
|
assert.match(zig, /rt\.cmdHostBytes\("store\.write", model\.draft\)/);
|
|
assert.match(
|
|
zig,
|
|
/rt\.cmdRequest\("store\.read", "load", @intFromEnum\(std\.meta\.Tag\(Msg\)\.loaded\), @intFromEnum\(std\.meta\.Tag\(Msg\)\.failed\), "notes\.json"\)/,
|
|
);
|
|
assert.match(zig, /rt\.cmdCancel\("load"\)/);
|
|
});
|
|
|
|
test("Cmd v2 record payloads lower through the derived encoder, fields sorted by name", () => {
|
|
const zig = emit(`
|
|
import { Cmd, asciiBytes } from "@native-sdk/core";
|
|
export interface Model { readonly gain: number; readonly on: boolean; }
|
|
export type Msg = { readonly kind: "save" } | { readonly kind: "noop" };
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "save": return [model, Cmd.host("cfg.save", { on: model.on, tag: asciiBytes("v2"), gain: model.gain })];
|
|
case "noop": return model;
|
|
}
|
|
}
|
|
`);
|
|
// gain (f64 LE) first, then on (bool byte), then tag (u32 len + bytes) —
|
|
// sorted by TS field name, exactly what sdk hostRecordBytes produces.
|
|
assert.match(zig, /const payload = rt\.frameAlloc\(u8, 13 \+ p_tag\.len\);/);
|
|
assert.match(zig, /std\.mem\.writeInt\(u64, payload\[0\.\.\]\[0\.\.8\], @bitCast\(@as\(f64, @floatFromInt\(model\.gain\)\)\), \.little\);/);
|
|
assert.match(zig, /payload\[8\] = @intFromBool\(model\.on\);/);
|
|
assert.match(zig, /std\.mem\.writeInt\(u32, payload\[8 \+ 1\.\.\]\[0\.\.4\], @intCast\(p_tag\.len\), \.little\);/);
|
|
assert.match(zig, /@memcpy\(payload\[8 \+ 1 \+ 4\.\.\]\[0\.\.p_tag\.len\], p_tag\);/);
|
|
assert.match(zig, /rt\.cmdHostBytes\("cfg\.save", payload\)/);
|
|
});
|
|
|
|
test("initialModel's boot pair emits InitResult with the same cmd channel", () => {
|
|
const zig = emit(`
|
|
import { Cmd } from "@native-sdk/core";
|
|
export interface Model { readonly count: number; }
|
|
export type Msg = { readonly kind: "tick"; readonly at: number } | { readonly kind: "noop" };
|
|
export function initialModel(): [Model, Cmd<Msg>] {
|
|
return [{ count: 0 }, Cmd.now("tick")];
|
|
}
|
|
export function update(model: Model, msg: Msg): Model { return model; }
|
|
`);
|
|
assert.match(zig, /pub const InitResult = struct \{ model: \*const Model, cmd: rt\.Cmd \};/);
|
|
assert.match(zig, /pub fn initialModel\(\) InitResult/);
|
|
assert.match(zig, /\.cmd = rt\.cmdNow\(@intFromEnum\(std\.meta\.Tag\(Msg\)\.tick\)\)/);
|
|
});
|
|
|
|
test("subscriptions lowers onto the rt subscription builders", () => {
|
|
const zig = emit(`
|
|
import { Sub } from "@native-sdk/core";
|
|
export interface Model { readonly running: boolean; readonly fast: boolean; }
|
|
export type Msg = { readonly kind: "toggle" } | { readonly kind: "tick"; readonly at: number };
|
|
export function update(model: Model, msg: Msg): Model { return model; }
|
|
export function subscriptions(model: Model): Sub<Msg> {
|
|
if (!model.running) return Sub.none;
|
|
return Sub.batch([Sub.timer("tick", model.fast ? 40 : 100, "tick")]);
|
|
}
|
|
`);
|
|
assert.match(zig, /pub fn subscriptions\(model: \*const Model\) rt\.Sub/);
|
|
assert.match(zig, /return rt\.sub_none;/);
|
|
assert.match(zig, /rt\.subBatch\(&\.\{ rt\.subTimer\("tick", /);
|
|
assert.match(zig, /@intFromEnum\(std\.meta\.Tag\(Msg\)\.tick\)\)/);
|
|
});
|
|
|
|
test("R2 host-boundary params stay f64 under comparison demand (round 2C regression)", () => {
|
|
const zig = emit(`
|
|
export function lenIs(bytes: Uint8Array, want: number): boolean {
|
|
return bytes.length === want;
|
|
}
|
|
export function firstIs(bytes: Uint8Array, want: number): boolean {
|
|
return bytes[0] === want;
|
|
}
|
|
export function pick(bytes: Uint8Array, i: number): number {
|
|
return bytes[i];
|
|
}
|
|
`);
|
|
// Comparison against i64-classed values (length, byte read) must not claim
|
|
// the exported parameter — the host may pass any f64; the integer side
|
|
// widens at the site instead.
|
|
assert.match(zig, /pub fn lenIs\(bytes: \[\]const u8, want: f64\) bool/);
|
|
assert.match(zig, /pub fn firstIs\(bytes: \[\]const u8, want: f64\) bool/);
|
|
// Genuine integer demand (a memory index) still claims the parameter:
|
|
// the emitted i64 signature is the contract type.
|
|
assert.match(zig, /pub fn pick\(bytes: \[\]const u8, i: i64\) i64/);
|
|
});
|
|
|
|
test("Cmd.now demands a single-number-payload Msg arm (tsc-level)", () => {
|
|
const result = transpile(`
|
|
import { Cmd } from "@native-sdk/core";
|
|
export interface Model { readonly count: number; }
|
|
export type Msg = { readonly kind: "add" } | { readonly kind: "tick"; readonly at: number };
|
|
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
|
|
switch (msg.kind) {
|
|
case "add": return [model, Cmd.now("add")];
|
|
case "tick": return model;
|
|
}
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.typeErrors.some((e) => e.includes("tick")), result.typeErrors.join("\n"));
|
|
});
|
|
|
|
test("emit-time verification: unsupported constructs stop the build as NS9001", () => {
|
|
const result = transpile(`
|
|
export function f(xs: readonly number[]): readonly number[] {
|
|
return xs.flatMap((a) => [a, a]);
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS9001"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R17i block-body callbacks lift: straight-line bodies inline, early returns become a labeled value block", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly streak: number; readonly done: boolean; }
|
|
export function margins(tasks: readonly Task[], lim: number): readonly Task[] {
|
|
return tasks.filter((t) => {
|
|
const margin = lim - t.streak;
|
|
return margin > 0;
|
|
});
|
|
}
|
|
export function capped(tasks: readonly Task[], lim: number): readonly Task[] {
|
|
return tasks.map((t) => {
|
|
if (t.streak > lim) return { ...t, streak: lim };
|
|
return t;
|
|
});
|
|
}
|
|
`);
|
|
// Straight-line lift: the local emits in the loop body, the trailing
|
|
// return is the predicate value.
|
|
assert.match(zig, /const margin = lim - t\.streak;/);
|
|
assert.match(zig, /if \(margin > 0\) \{/);
|
|
// Early returns: a labeled value block; `return v` -> `break :label v`.
|
|
assert.match(zig, /const elem: Task = cb: \{/);
|
|
assert.match(zig, /break :cb t;/);
|
|
});
|
|
|
|
test("R17i block-body find and reduce lift with the same rules", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly streak: number; readonly done: boolean; }
|
|
export function pick(tasks: readonly Task[], lim: number): number {
|
|
const hit = tasks.find((t) => {
|
|
if (t.done) return false;
|
|
return t.streak > lim;
|
|
});
|
|
if (hit === undefined) return -1;
|
|
return hit.id;
|
|
}
|
|
export function tally(tasks: readonly Task[]): number {
|
|
return tasks.reduce((sum, t) => {
|
|
if (t.done) return sum;
|
|
return sum + t.streak;
|
|
}, 0);
|
|
}
|
|
`);
|
|
assert.match(zig, /const matches: bool = cb: \{/);
|
|
assert.match(zig, /break :cb false;/);
|
|
// `streak` compares against the host-boundary `lim` (which must stay f64,
|
|
// R2), so the accumulator chain resolves float instead of being claimed to
|
|
// i64 by the comparison.
|
|
assert.match(zig, /const next: f64 = cb: \{/);
|
|
assert.match(zig, /break :cb sum \+ t\.streak;/);
|
|
});
|
|
|
|
test("R17i a callback path that falls off the end is a taught stop", () => {
|
|
const result = transpile(`
|
|
export function f(xs: readonly number[]): readonly number[] {
|
|
return xs.filter((x) => {
|
|
if (x > 0) return true;
|
|
});
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS9001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("explicit `return`"), d.message);
|
|
});
|
|
|
|
test("R17j toSorted copies then runs a stable insertion sort on the comparator's sign", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly streak: number; }
|
|
export function byStreak(tasks: readonly Task[]): readonly Task[] {
|
|
return tasks.toSorted((a, b) => a.streak - b.streak);
|
|
}
|
|
`);
|
|
assert.match(zig, /const sorted = rt\.frameAlloc\(Task, tasks\.len\);/);
|
|
assert.match(zig, /@memcpy\(sorted, tasks\);/);
|
|
assert.match(zig, /stable insertion sort/);
|
|
assert.match(zig, /const a = sorted\[i\];/);
|
|
assert.match(zig, /const b = sorted\[j - 1\];/);
|
|
assert.match(zig, /if \(!\(a\.streak - b\.streak < 0\)\) break;/);
|
|
assert.match(zig, /sorted\[j\] = a;/);
|
|
});
|
|
|
|
test("R17j toSorted without a comparator is a taught stop naming (a, b) => a - b", () => {
|
|
const result = transpile(`
|
|
export function f(xs: readonly number[]): readonly number[] {
|
|
return xs.toSorted();
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS9001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("(a, b) => a - b"), d.message);
|
|
});
|
|
|
|
test("R17k the push-builder lowers to a growable frame builder", () => {
|
|
const zig = emit(`
|
|
export function evens(xs: readonly number[]): readonly number[] {
|
|
const out: number[] = [];
|
|
for (const x of xs) {
|
|
if (x > 0) out.push(x);
|
|
}
|
|
return out;
|
|
}
|
|
`);
|
|
assert.match(zig, /var out = rt\.frameAlloc\(f64, 0\);/);
|
|
assert.match(zig, /var out_len: usize = 0;/);
|
|
assert.match(zig, /if \(out_len == out\.len\) out = rt\.frameGrow\(f64, out\);/);
|
|
assert.match(zig, /out\[out_len\] = x;/);
|
|
assert.match(zig, /out_len \+= 1;/);
|
|
assert.match(zig, /return out\[0\.\.out_len\];/);
|
|
});
|
|
|
|
test("R17m a reassigned binding whose every assignment is a fresh construction stays owned", () => {
|
|
const zig = emit(`
|
|
export function f(n: number): readonly number[] {
|
|
let out: number[] = [];
|
|
out.push(n);
|
|
out = [n, n];
|
|
out.push(n + 1);
|
|
return out;
|
|
}
|
|
`);
|
|
// The reassignment resets the builder's slice and fill count.
|
|
assert.match(zig, /out = rt\.frameAlloc\(f64, 0\);/);
|
|
assert.match(zig, /out_len = out\.len;/);
|
|
});
|
|
|
|
test("R17m push on a MIXED reassigned binding is taught (one alias assignment ends ownership)", () => {
|
|
const result = transpile(`
|
|
export function f(xs: number[], n: number): readonly number[] {
|
|
let out: number[] = [];
|
|
out.push(n);
|
|
out = xs;
|
|
return out;
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS1001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("reassigned"), d.message);
|
|
});
|
|
|
|
test("R17k push on a helper-produced array is taught with the ownership fix", () => {
|
|
const result = transpile(`
|
|
function make(): number[] { return [1]; }
|
|
export function f(n: number): readonly number[] {
|
|
const out = make();
|
|
out.push(n);
|
|
return out;
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS1001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes(".slice()"), d.message);
|
|
});
|
|
|
|
test("R17k the generalized builder: pop/shift/unshift/splice lower against the live length", () => {
|
|
const zig = emit(`
|
|
export function churn(xs: readonly number[]): readonly number[] {
|
|
const work = xs.slice();
|
|
work.pop();
|
|
work.shift();
|
|
work.unshift(-1);
|
|
work.splice(1, 0, 9);
|
|
return work;
|
|
}
|
|
`);
|
|
assert.match(zig, /var work = work_seed;/);
|
|
assert.match(zig, /var work_len: usize = work\.len;/);
|
|
assert.match(zig, /if \(work_len > 0\) work_len -= 1;/, "pop drops the tail");
|
|
assert.match(zig, /std\.mem\.copyForwards\(f64, work\[0\.\.work_len\], work\[1\.\.work_len \+ 1\]\);/, "shift memmoves the head");
|
|
assert.match(zig, /std\.mem\.copyBackwards\(f64, work\[1\.\.work_len \+ 1\], work\[0\.\.work_len\]\);/, "unshift shifts right");
|
|
assert.match(zig, /rt\.sliceIndex\(work_len, 1\)/, "splice resolves its start the JS way");
|
|
assert.match(zig, /return work\[0\.\.work_len\];/);
|
|
});
|
|
|
|
test("R17j applied in place: an owned sort reuses the stable insertion sort without a copy", () => {
|
|
const zig = emit(`
|
|
export function ordered(xs: readonly number[]): readonly number[] {
|
|
const copy = xs.slice();
|
|
copy.sort((a, b) => a - b);
|
|
return copy;
|
|
}
|
|
`);
|
|
assert.match(zig, /\/\/ JS sort: stable insertion sort; the comparator's sign orders each pair\./);
|
|
assert.match(zig, /if \(!\(a - b < 0\)\) break;/);
|
|
// No second allocation between the slice copy and the sort.
|
|
const allocs = zig.match(/rt\.frameAlloc\(f64/g) ?? [];
|
|
assert.equal(allocs.length, 1, `expected exactly the slice copy's alloc:\n${zig}`);
|
|
});
|
|
|
|
test("R17k a later push argument reading the array is hoisted (JS evaluates args first)", () => {
|
|
const zig = emit(`
|
|
export function f(n: number): readonly number[] {
|
|
const out: number[] = [];
|
|
out.push(n);
|
|
out.push(n + 1, out.length);
|
|
return out;
|
|
}
|
|
`);
|
|
assert.match(zig, /const pushed = n \+ 1;/);
|
|
assert.match(zig, /const pushed_2 = @as\(f64, @floatFromInt\(@as\(i64, @intCast\(out_len\)\)\)\);/);
|
|
});
|
|
|
|
test("R17k push in value position is a taught stop (the JS value is the new length)", () => {
|
|
const result = transpile(`
|
|
export function f(n: number): number {
|
|
const out: number[] = [];
|
|
const len = out.push(n);
|
|
return len;
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS9001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("new length"), d.message);
|
|
});
|
|
|
|
test("R17l prepend and multi-spread array literals copy exact-size segments", () => {
|
|
const zig = emit(`
|
|
export function pre(xs: readonly number[], x: number): readonly number[] {
|
|
return [x, ...xs];
|
|
}
|
|
export function stitch(a: readonly number[], b: readonly number[], x: number): readonly number[] {
|
|
return [...a, x, ...b];
|
|
}
|
|
`);
|
|
// Prepend: static leading indices, one tail memcpy.
|
|
assert.match(zig, /rt\.frameAlloc\(f64, xs\.len \+ 1\);/);
|
|
assert.match(zig, /prepended\[0\] = x;/);
|
|
assert.match(zig, /@memcpy\(prepended\[1\.\.\], xs\);/);
|
|
// Multi-spread: one exact-size alloc, cursor-advanced segment copies.
|
|
assert.match(zig, /rt\.frameAlloc\(f64, a\.len \+ b\.len \+ 1\);/);
|
|
assert.match(zig, /@memcpy\(combined\[combined_at\.\.\]\[0\.\.a\.len\], a\);/);
|
|
assert.match(zig, /combined\[combined_at\] = x;/);
|
|
});
|
|
|
|
test("comptime division folds to its JS f64 value (0/0 is NaN, never a Zig divide error)", () => {
|
|
const zig = emit(`
|
|
export const RATE = 1 / 0;
|
|
export function nanv(): number { return 0 / 0; }
|
|
export function half(): number { return 5 / 2; }
|
|
export function negZero(): number { return 0 / -1; }
|
|
`);
|
|
assert.match(zig, /pub const RATE: f64 = std\.math\.inf\(f64\);/);
|
|
assert.match(zig, /return std\.math\.nan\(f64\);/);
|
|
assert.match(zig, /return 2\.5;/);
|
|
assert.match(zig, /return -0\.0;/);
|
|
});
|
|
|
|
test("R8b Math floor/ceil/trunc/abs/sign on float sites map to the IEEE ops (sign via rt.jsSign)", () => {
|
|
const zig = emit(`
|
|
export function flo(x: number): number { return Math.floor(x); }
|
|
export function cei(x: number): number { return Math.ceil(x); }
|
|
export function tru(x: number): number { return Math.trunc(x); }
|
|
export function ab(x: number): number { return Math.abs(x); }
|
|
export function sg(x: number): number { return Math.sign(x); }
|
|
export function sq(x: number): number { return Math.sqrt(x); }
|
|
`);
|
|
assert.match(zig, /return @floor\(x\);/);
|
|
assert.match(zig, /return @ceil\(x\);/);
|
|
assert.match(zig, /return @trunc\(x\);/);
|
|
assert.match(zig, /return @abs\(x\);/);
|
|
assert.match(zig, /return rt\.jsSign\(x\);/);
|
|
assert.match(zig, /return @sqrt\(x\);/);
|
|
});
|
|
|
|
test("R8b Math floor/ceil/trunc of an integer-classed value are the identity; abs/sign stay integer", () => {
|
|
const zig = emit(`
|
|
export function flo(bytes: Uint8Array): number { return Math.floor(bytes.length); }
|
|
export function ab(bytes: Uint8Array): number { return Math.abs(bytes.length - 10); }
|
|
export function sg(bytes: Uint8Array): number { return Math.sign(bytes.length - 2); }
|
|
`);
|
|
assert.match(zig, /fn flo\(bytes: \[\]const u8\) i64 \{\n return jlen\(bytes\);/);
|
|
assert.match(zig, /return @as\(i64, @intCast\(@abs\(jlen\(bytes\) - 10\)\)\);/);
|
|
assert.match(zig, /return std\.math\.sign\(jlen\(bytes\) - 2\);/);
|
|
});
|
|
|
|
test("R8 comptime Math calls fold to their JS value (integer folds stay integer-classed)", () => {
|
|
const zig = emit(`
|
|
export const HALF = Math.floor(5 / 2);
|
|
export const DOWN = Math.floor(-5 / 2);
|
|
export const ROOT = Math.sqrt(2);
|
|
export function pick(bytes: Uint8Array): number { return bytes[HALF]; }
|
|
export function low(): number { return Math.min(3, 2.5); }
|
|
`);
|
|
assert.match(zig, /pub const HALF: i64 = 2;/);
|
|
assert.match(zig, /pub const DOWN: i64 = -3;/);
|
|
assert.match(zig, /pub const ROOT: f64 = 1\.4142135623730951;/);
|
|
assert.match(zig, /return 2\.5;/);
|
|
});
|
|
|
|
test("R8 Math.min()/Math.max() with no arguments fold to the JS infinities; one argument is the identity", () => {
|
|
const zig = emit(`
|
|
export function lo(): number { return Math.min(); }
|
|
export function hi(): number { return Math.max(); }
|
|
export function same(x: number): number { return Math.max(x); }
|
|
`);
|
|
assert.match(zig, /return std\.math\.inf\(f64\);/);
|
|
assert.match(zig, /return -std\.math\.inf\(f64\);/);
|
|
assert.match(zig, /return x;/);
|
|
});
|
|
|
|
test("Number.isInteger/isFinite/isNaN classify floats; integer-classed arguments are constants", () => {
|
|
const zig = emit(`
|
|
export function isInt(x: number): boolean { return Number.isInteger(x); }
|
|
export function isFin(x: number): boolean { return Number.isFinite(x); }
|
|
export function isNan(x: number): boolean { return Number.isNaN(x); }
|
|
export function intAlways(bytes: Uint8Array): boolean { return Number.isInteger(bytes.length); }
|
|
export function intNever(bytes: Uint8Array): boolean { return Number.isNaN(bytes.length); }
|
|
`);
|
|
assert.match(zig, /return rt\.jsIsInteger\(x\);/);
|
|
assert.match(zig, /return std\.math\.isFinite\(x\);/);
|
|
assert.match(zig, /return std\.math\.isNan\(x\);/);
|
|
assert.match(zig, /fn intAlways\(bytes: \[\]const u8\) bool \{\n _ = bytes;\n return true;/);
|
|
assert.match(zig, /fn intNever\(bytes: \[\]const u8\) bool \{\n _ = bytes;\n return false;/);
|
|
});
|
|
|
|
test("the NaN and Infinity globals are f64 literals, never leaked identifiers", () => {
|
|
const zig = emit(`
|
|
export function nanv(): number { return NaN; }
|
|
export function negInf(): number { return -Infinity; }
|
|
export function isMissing(x: number): boolean { return Number.isNaN(x); }
|
|
`);
|
|
assert.match(zig, /return std\.math\.nan\(f64\);/);
|
|
assert.match(zig, /return -std\.math\.inf\(f64\);/);
|
|
});
|
|
|
|
test("comptime % folds to its JS value (5 % 0 is NaN, -5 % 5 is -0; integer folds stay comptime indices)", () => {
|
|
const zig = emit(`
|
|
export function nanv(): number { return 5 % 0; }
|
|
export function negZero(): number { return -5 % 5; }
|
|
export function frac(): number { return 5.5 % 2; }
|
|
export function pick(bytes: Uint8Array): number { return bytes[6 % 4]; }
|
|
`);
|
|
assert.match(zig, /return std\.math\.nan\(f64\);/);
|
|
assert.match(zig, /return -0\.0;/);
|
|
assert.match(zig, /return 1\.5;/);
|
|
assert.match(zig, /return bytes\[uz\(2\)\];/);
|
|
});
|
|
|
|
test("R11 subarray resolves both bounds the JS way through rt.subarray (a view, never a trap)", () => {
|
|
const zig = emit(`
|
|
export function window(bytes: Uint8Array, from: number, to: number): Uint8Array {
|
|
return bytes.subarray(from, to);
|
|
}
|
|
export function tail(bytes: Uint8Array, from: number): Uint8Array {
|
|
return bytes.subarray(from);
|
|
}
|
|
export function whole(bytes: Uint8Array): Uint8Array {
|
|
return bytes.subarray();
|
|
}
|
|
`);
|
|
assert.match(zig, /return rt\.subarray\(bytes, from, to\);/);
|
|
assert.match(zig, /return rt\.subarray\(bytes, from, null\);/);
|
|
assert.match(zig, /fn whole\(bytes: \[\]const u8\) \[\]const u8 \{\n return bytes;/);
|
|
});
|
|
|
|
test("R1b module const tables emit as rodata: arrays, records, enum members, asciiBytes names", () => {
|
|
const zig = emit(`
|
|
import { asciiBytes } from "@native-sdk/core";
|
|
export type Filter = "all" | "active" | "done";
|
|
export const WEEKDAYS = [3, 5, 2];
|
|
export const ORDER: readonly Filter[] = ["done", "all"];
|
|
export const SEED_NAME = asciiBytes("Stretch");
|
|
export interface Limits { readonly lo: number; readonly hi: number; }
|
|
export const LIMITS: Limits = { lo: 1, hi: 9 };
|
|
export const DEFAULT_FILTER: Filter = "active";
|
|
export function at(i: number): number { return WEEKDAYS[i]; }
|
|
export function count(): number { return WEEKDAYS.length; }
|
|
export function loOf(): number { return LIMITS.lo; }
|
|
`);
|
|
assert.match(zig, /pub const WEEKDAYS: \[\]const f64 = &\.\{ 3, 5, 2 \};/);
|
|
assert.match(zig, /pub const ORDER: \[\]const Filter = &\.\{ \.done, \.all \};/);
|
|
assert.match(zig, /pub const SEED_NAME: \[\]const u8 = "Stretch";/);
|
|
assert.match(zig, /pub const LIMITS: Limits = \.\{ \.lo = 1, \.hi = 9 \};/);
|
|
assert.match(zig, /pub const DEFAULT_FILTER: Filter = \.active;/);
|
|
assert.match(zig, /return WEEKDAYS\[uz\(i\)\];/);
|
|
assert.match(zig, /return LIMITS\.lo;/);
|
|
});
|
|
|
|
test("R1b a record table of model structs emits pointer elements the commit walkers share", () => {
|
|
const zig = emit(`
|
|
import { asciiBytes } from "@native-sdk/core";
|
|
export interface Task { readonly id: number; readonly title: Uint8Array; readonly done: boolean; }
|
|
export interface Model { readonly tasks: readonly Task[]; }
|
|
export const SEEDS: readonly Task[] = [
|
|
{ id: 1, title: asciiBytes("Ship"), done: false },
|
|
{ id: 2, title: asciiBytes("Test"), done: true },
|
|
];
|
|
export function initialModel(): Model { return { tasks: SEEDS }; }
|
|
`);
|
|
assert.match(zig, /pub const SEEDS: \[\]const \*const Task = &\.\{ &\.\{ \.id = 1, \.title = "Ship", \.done = false \}, &\.\{ \.id = 2, \.title = "Test", \.done = true \} \};/);
|
|
});
|
|
|
|
test("R1b an unannotated record table is taught toward the interface annotation", () => {
|
|
const result = transpile(`
|
|
export const LIMITS = { lo: 1, hi: 9 };
|
|
export function loOf(): number { return 1; }
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
const d = result.diagnostics.find((x) => x.id === "NS9001");
|
|
assert.ok(d, JSON.stringify(result.diagnostics));
|
|
assert.ok(d.message.includes("interface annotation"), d.message);
|
|
});
|
|
|
|
test("R1b spreads and non-comptime values in tables are taught stops", () => {
|
|
const spread = transpile(`
|
|
export interface Limits { readonly lo: number; readonly hi: number; }
|
|
export const BASE: Limits = { lo: 1, hi: 9 };
|
|
export const WIDE: Limits = { ...BASE, hi: 99 };
|
|
export function f(): number { return 1; }
|
|
`);
|
|
assert.equal(spread.ok, false);
|
|
assert.ok(spread.diagnostics.some((d) => d.id === "NS9001" && d.message.includes("spread")), JSON.stringify(spread.diagnostics));
|
|
});
|
|
|
|
test("R17b a type-changing map allocates the callback's result type, not the source element", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly title: Uint8Array; readonly done: boolean; }
|
|
export function ids(tasks: readonly Task[]): readonly number[] {
|
|
return tasks.map((t) => t.id);
|
|
}
|
|
export function titles(tasks: readonly Task[]): readonly Uint8Array[] {
|
|
return tasks.map((t) => t.title);
|
|
}
|
|
export function orNull(xs: readonly number[]): readonly (number | null)[] {
|
|
return xs.map((x) => (x > 0 ? x : null));
|
|
}
|
|
`);
|
|
assert.match(zig, /const mapped = rt\.frameAlloc\(f64, tasks\.len\);/);
|
|
assert.match(zig, /mapped\[i\] = @as\(f64, @floatFromInt\(t\.id\)\);/);
|
|
assert.match(zig, /rt\.frameAlloc\(\[\]const u8, tasks\.len\);/);
|
|
assert.match(zig, /rt\.frameAlloc\(\?f64, xs\.len\);/);
|
|
});
|
|
|
|
test("R17m callback index parameters bind the loop index in the parameter's class", () => {
|
|
const zig = emit(`
|
|
export function weighted(xs: readonly number[]): readonly number[] {
|
|
return xs.map((x, i) => x * i);
|
|
}
|
|
export function evenSlots(xs: readonly number[]): readonly number[] {
|
|
return xs.filter((x, at) => at % 2 === 0);
|
|
}
|
|
export function afterTwo(xs: readonly number[]): number {
|
|
return xs.findIndex((x, at) => at >= 2 && x > 0);
|
|
}
|
|
`);
|
|
// The map index mixes with float elements, so it binds as f64. The source
|
|
// name `i` wins; the generated loop capture yields (`i_2`, never a bare
|
|
// digit suffix that would land in Zig's iN primitive namespace).
|
|
assert.match(zig, /const i: f64 = @floatFromInt\(i_2\);/);
|
|
// Pure index arithmetic stays i64.
|
|
assert.match(zig, /const at: i64 = @intCast\(i\);/);
|
|
assert.match(zig, /@rem\(at, 2\)/);
|
|
});
|
|
|
|
test("R17m a callback declaring the third (array) parameter is a taught stop", () => {
|
|
const result = transpile(`
|
|
export function f(xs: readonly number[]): readonly number[] {
|
|
return xs.map((x, i, all) => x + all.length);
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS9001" && d.message.includes("element, index")), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("array-method calls lower directly inside if conditions (the scan hoists before the if)", () => {
|
|
const zig = emit(`
|
|
export function grade(xs: readonly number[]): number {
|
|
if (xs.some((x) => x > 3)) return 1;
|
|
else if (xs.every((x) => x < 0)) return 2;
|
|
return 0;
|
|
}
|
|
`);
|
|
assert.match(zig, /var any_match = false;/);
|
|
assert.match(zig, /if \(any_match\) \{/);
|
|
assert.match(zig, /var all_match = true;/);
|
|
assert.match(zig, /if \(all_match\) return 2;/);
|
|
});
|
|
|
|
test("names that would shadow Zig primitives take the reserved-word underscore", () => {
|
|
const zig = emit(`
|
|
export function f(xs: readonly number[], u8: number): number {
|
|
let total = 0;
|
|
for (const x of xs) total += x * u8;
|
|
return total;
|
|
}
|
|
`);
|
|
assert.match(zig, /u8_: f64/);
|
|
});
|
|
|
|
test("R13d switch on a literal-union value maps member arms, shared labels, and default to else", () => {
|
|
const zig = emit(`
|
|
export type Filter = "all" | "active" | "done";
|
|
export function sharedBody(f: Filter): number {
|
|
switch (f) {
|
|
case "active":
|
|
case "done":
|
|
return 1;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
export function partial(f: Filter): number {
|
|
let n = 0;
|
|
switch (f) {
|
|
case "done":
|
|
n = 5;
|
|
break;
|
|
}
|
|
return n;
|
|
}
|
|
`);
|
|
assert.match(zig, /\.active, \.done => return 1,/);
|
|
assert.match(zig, /else => return 0,/);
|
|
// A partial value switch skips uncovered members exactly like JS.
|
|
assert.match(zig, /else => \{\},/);
|
|
});
|
|
|
|
test("kernel capacities: default header uses the shared default kernel", () => {
|
|
const zig = emit(`export function f(n: number): number { return n & 1; }`);
|
|
assert.match(zig, /pub const rt = @import\("rt\.zig"\)\.default;/);
|
|
});
|
|
|
|
test("kernel capacities: transpile options become comptime Kernel parameters", () => {
|
|
const result = transpile(
|
|
`export function f(n: number): number { return n & 1; }`,
|
|
{ frameCap: 262144, heapCap: 524288 },
|
|
);
|
|
assert.equal(result.ok, true);
|
|
assert.match(result.zig!, /pub const rt = @import\("rt\.zig"\)\.Kernel\(\.\{ \.frame_cap = 262144, \.heap_cap = 524288 \}\);/);
|
|
});
|
|
|
|
test("R6b a model union emits an arm-switching walker; scalar arms pass through", () => {
|
|
const zig = emit(`
|
|
import { asciiBytes } from "@native-sdk/core";
|
|
export type View =
|
|
| { readonly kind: "list"; readonly scroll: number }
|
|
| { readonly kind: "compose"; readonly draft: Uint8Array };
|
|
export interface Model { readonly view: View; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { view: { kind: "list", scroll: 0 } }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) {
|
|
case "a": return { view: { kind: "compose", draft: asciiBytes("d") } };
|
|
case "b": return { view: { kind: "list", scroll: 1 } };
|
|
}
|
|
}
|
|
`);
|
|
assert.match(zig, /fn commitView\(value: View, mode: CommitMode\) View \{/);
|
|
// The scalar arm shares by value; the bytes arm rewrites its payload.
|
|
assert.match(zig, /\.list => value,/);
|
|
assert.match(zig, /\.compose => \|v\| \.\{ \.compose = commitBytes\(v, mode\) \},/);
|
|
assert.match(zig, /\.view = commitView\(value\.view, mode\)/);
|
|
});
|
|
|
|
test("R6b a union whose arms hold no heap data commits by plain copy", () => {
|
|
const zig = emit(`
|
|
export type View =
|
|
| { readonly kind: "list" }
|
|
| { readonly kind: "detail"; readonly id: number };
|
|
export interface Model { readonly view: View; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { view: { kind: "list" } }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return { view: { kind: "detail", id: 1 } }; }
|
|
}
|
|
`);
|
|
assert.match(zig, /\.view = value\.view,/);
|
|
assert.doesNotMatch(zig, /fn commitView/);
|
|
});
|
|
|
|
test("primitive model arrays commit through the generic scalar walker", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly xs: readonly number[]; readonly flags: readonly boolean[]; readonly pending: readonly number[] | null; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { xs: [], flags: [], pending: null }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return { ...model, xs: [] }; }
|
|
}
|
|
`);
|
|
assert.match(zig, /fn commitScalars\(comptime T: type, values: \[\]const T, mode: CommitMode\) \[\]const T \{/);
|
|
assert.match(zig, /\.xs = commitScalars\(f64, value\.xs, mode\)/);
|
|
assert.match(zig, /\.flags = commitScalars\(bool, value\.flags, mode\)/);
|
|
assert.match(zig, /\.pending = if \(value\.pending\) \|opt\| commitScalars\(f64, opt, mode\) else null/);
|
|
});
|
|
|
|
test("R7d null-guarded && chains take the three spellings by position", () => {
|
|
const zig = emit(`
|
|
export interface Sel { readonly items: readonly number[]; readonly at: number; }
|
|
export interface Model { readonly sel: Sel | null; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { sel: null }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return { sel: null }; }
|
|
}
|
|
export function hasItems(model: Model): boolean {
|
|
if (model.sel !== null && model.sel.items.length > 0) return true;
|
|
return false;
|
|
}
|
|
export function asValue(model: Model): boolean {
|
|
return model.sel !== null && model.sel.at > 0;
|
|
}
|
|
export function ternary(model: Model): number {
|
|
return model.sel !== null && model.sel.at > 0 ? model.sel.at : -1;
|
|
}
|
|
`);
|
|
// Statement without else: nested R7 capture blocks.
|
|
assert.match(zig, /if \(model\.sel\) \|sel\| \{/);
|
|
// Value position: the capturing if-expression.
|
|
assert.match(zig, /\(if \(model\.sel\) \|sel\| sel\.at > 0 else false\)/);
|
|
// Ternary: unwrap spelling so the narrowed branch can read the target.
|
|
assert.match(zig, /if \(model\.sel != null and model\.sel\.\?\.at > 0\) model\.sel\.\?\.at else -1/);
|
|
});
|
|
|
|
test("R7d the || dual narrows after an exiting guard", () => {
|
|
const zig = emit(`
|
|
export interface Sel { readonly items: readonly number[]; readonly at: number; }
|
|
export function firstOrMinus(sel: Sel | null): number {
|
|
if (sel === null || sel.items.length === 0) return -1;
|
|
return sel.items[0];
|
|
}
|
|
`);
|
|
assert.match(zig, /if \(sel == null or @as\(i64, @intCast\(sel\.\?\.items\.len\)\) == 0\) \{/);
|
|
assert.match(zig, /return sel\.\?\.items\[0\];/);
|
|
});
|
|
|
|
test("-0 emits the f64 literal, never the integer zero", () => {
|
|
const zig = emit(`
|
|
export function negZero(): number { return -0; }
|
|
export function prodNegZero(): number { return 0 * -1; }
|
|
`);
|
|
assert.match(zig, /pub fn negZero\(\) f64 \{\n return -0\.0;/);
|
|
assert.match(zig, /pub fn prodNegZero\(\) f64 \{\n return -0\.0;/);
|
|
});
|
|
|
|
test("exported single-Model-param helpers forward as Model declarations (markup fn-backed scalars)", () => {
|
|
const zig = emit(`
|
|
export interface Task { readonly id: number; readonly done: boolean; }
|
|
export interface Model { readonly tasks: readonly Task[]; readonly nextId: number; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { tasks: [], nextId: 1 }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return model; }
|
|
}
|
|
export function remainingCount(model: Model): number {
|
|
return model.tasks.filter((t) => !t.done).length;
|
|
}
|
|
export function openTasks(model: Model): readonly Task[] {
|
|
return model.tasks.filter((t) => !t.done);
|
|
}
|
|
function internalHelper(model: Model): number { return model.nextId; }
|
|
export function twoParams(model: Model, n: number): number { return model.nextId + n; }
|
|
`);
|
|
// The forwarder rides the Model struct under the helper's own TS name
|
|
// (markup binds {remainingCount}) with the fn-backed-scalar shape
|
|
// (fn (self: *const Model) V).
|
|
assert.match(zig, /pub fn remainingCount\(self: \*const Model\) i64 \{\n return core_root\.remainingCount\(self\);/);
|
|
// Slice-returning helpers forward too (markup iterables).
|
|
assert.match(zig, /pub fn openTasks\(self: \*const Model\) \[\]const \*const Task \{/);
|
|
// Unexported and multi-parameter functions never forward.
|
|
assert.ok(!zig.includes("internalHelper(self"), "unexported helper must not forward");
|
|
assert.ok(!zig.includes("twoParams(self"), "multi-param helper must not forward");
|
|
// The module-level helper is still emitted for wiring/tests to call.
|
|
assert.match(zig, /pub fn remainingCount\(model: \*const Model\) i64 \{/);
|
|
});
|
|
|
|
test("a helper whose forwarder shares its module-level name forwards through core_root (no self-recursion)", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly count: number; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function initialModel(): Model { return { count: 0 }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return model; }
|
|
}
|
|
export function total(model: Model): number { return model.count + 1; }
|
|
`);
|
|
assert.match(zig, /const core_root = @This\(\);/);
|
|
assert.match(zig, /pub fn total\(self: \*const Model\) i64 \{\n return core_root\.total\(self\);/);
|
|
});
|
|
|
|
test("update/initialModel/subscriptions never forward as Model declarations", () => {
|
|
const zig = emit(`
|
|
import { Sub } from "@native-sdk/core";
|
|
export interface Model { readonly on: boolean; }
|
|
export type Msg = { readonly kind: "tick"; readonly at: number } | { readonly kind: "stop" };
|
|
export function initialModel(): Model { return { on: true }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "tick": return model; case "stop": return { on: false }; }
|
|
}
|
|
export function subscriptions(model: Model): Sub<Msg> {
|
|
if (!model.on) return Sub.none;
|
|
return Sub.timer("tick", 1000, "tick");
|
|
}
|
|
`);
|
|
assert.ok(!zig.includes("pub fn subscriptions(self"), "subscriptions must not forward");
|
|
assert.ok(!zig.includes("pub fn update(self"), "update must not forward");
|
|
});
|
|
|
|
test("viewUnbound emits the view_unbound opt-out on Model and Msg, never a rodata table", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly count: number; readonly nextId: number; }
|
|
export type Msg = { readonly kind: "add" } | { readonly kind: "chrome_changed"; readonly inset: number };
|
|
export const viewUnbound = ["nextId", "chrome_changed", "auditCount"] as const;
|
|
export function initialModel(): Model { return { count: 0, nextId: 1 }; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "add": return { ...model, count: model.count + 1, nextId: model.nextId + 1 }; case "chrome_changed": return model; }
|
|
}
|
|
export function auditCount(model: Model): number { return model.count; }
|
|
`);
|
|
assert.match(zig, /pub const Model = struct \{[\s\S]*?pub const view_unbound = \.\{ "nextId", "auditCount" \};[\s\S]*?\};/);
|
|
assert.match(zig, /pub const Msg = union\(enum\) \{[\s\S]*?pub const view_unbound = \.\{ "chrome_changed" \};[\s\S]*?\};/);
|
|
assert.ok(!zig.includes("viewUnbound"), "the config list itself never emits");
|
|
});
|
|
|
|
test("R7B envMsgs emits the comptime tuple the wiring walks", () => {
|
|
const zig = emit(`
|
|
export interface Model { readonly base: Uint8Array; }
|
|
export type Msg =
|
|
| { readonly kind: "noop" }
|
|
| { readonly kind: "set_base"; readonly value: Uint8Array };
|
|
export const envMsgs = [{ env: "NATIVE_SDK_MUSIC_URL_BASE", msg: "set_base" }] as const;
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) {
|
|
case "noop": return model;
|
|
case "set_base": return { base: msg.value };
|
|
}
|
|
}
|
|
`);
|
|
assert.match(zig, /pub const envMsgs = \.\{/);
|
|
assert.match(zig, /\.\{ \.env = "NATIVE_SDK_MUSIC_URL_BASE", \.msg = "set_base" \}/);
|
|
});
|
|
|
|
test("NS1033 envMsgs targeting a non-bytes arm is taught", () => {
|
|
const result = transpile(`
|
|
export interface Model { readonly n: number; }
|
|
export type Msg = { readonly kind: "noop" } | { readonly kind: "tick"; readonly at: number };
|
|
export const envMsgs = [{ env: "X", msg: "tick" }] as const;
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "noop": return model; case "tick": return { n: msg.at }; }
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("NS1033 appearanceMsg requires the appearance record shape", () => {
|
|
const result = transpile(`
|
|
export interface Model { readonly n: number; }
|
|
export type Msg = { readonly kind: "noop" } | { readonly kind: "flipped"; readonly dark: boolean };
|
|
export const appearanceMsg = "flipped";
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "noop": return model; case "flipped": return { n: msg.dark ? 1 : 0 }; }
|
|
}
|
|
`);
|
|
assert.equal(result.ok, false);
|
|
assert.ok(result.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(result.diagnostics));
|
|
});
|
|
|
|
test("R7B the appearance and chrome channels emit with their declared records", () => {
|
|
const zig = emit(`
|
|
export type ColorScheme = "light" | "dark";
|
|
export interface ChromeInsets { readonly top: number; readonly right: number; readonly bottom: number; readonly left: number; }
|
|
export interface ChromeButtons { readonly x: number; readonly y: number; readonly width: number; readonly height: number; }
|
|
export interface Model { readonly dark: boolean; readonly pad: number; }
|
|
export type Msg =
|
|
| { readonly kind: "appearance_changed"; readonly colorScheme: ColorScheme; readonly reduceMotion: boolean; readonly highContrast: boolean }
|
|
| { readonly kind: "chrome_changed"; readonly insets: ChromeInsets; readonly buttons: ChromeButtons; readonly tabsProjected: boolean };
|
|
export const appearanceMsg = "appearance_changed";
|
|
export const chromeMsg = "chrome_changed";
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) {
|
|
case "appearance_changed": return { ...model, dark: msg.colorScheme === "dark" };
|
|
case "chrome_changed": return { ...model, pad: msg.insets.top };
|
|
}
|
|
}
|
|
`);
|
|
assert.match(zig, /pub const appearanceMsg = "appearance_changed";/);
|
|
assert.match(zig, /pub const chromeMsg = "chrome_changed";/);
|
|
assert.match(zig, /appearance_changed: struct \{ colorScheme: ColorScheme, reduceMotion: bool, highContrast: bool \}/);
|
|
});
|
|
|
|
test("NS1033 frameMsg and keyMsg shapes are held to the channel contract", () => {
|
|
const bad_frame = transpile(`
|
|
export interface Model { readonly w: number; }
|
|
export interface FrameEvent { readonly width: number; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function frameMsg(model: Model, frame: FrameEvent): Msg | null { return null; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return model; }
|
|
}
|
|
`);
|
|
assert.equal(bad_frame.ok, false);
|
|
assert.ok(bad_frame.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_frame.diagnostics));
|
|
|
|
const bad_key = transpile(`
|
|
export interface Model { readonly w: number; }
|
|
export interface KeyEvent { readonly key: string; }
|
|
export type Msg = { readonly kind: "a" } | { readonly kind: "b" };
|
|
export function keyMsg(key: KeyEvent): Msg | null { return null; }
|
|
export function update(model: Model, msg: Msg): Model {
|
|
switch (msg.kind) { case "a": return model; case "b": return model; }
|
|
}
|
|
`);
|
|
assert.equal(bad_key.ok, false);
|
|
assert.ok(bad_key.diagnostics.some((d) => d.id === "NS1033"), JSON.stringify(bad_key.diagnostics));
|
|
});
|