ludic/tools/x/test.ludic
Orkuncakilkaya 9452557f3c
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 17s
ci / build-and-test (push) Successful in 1m15s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 19s
feat(reflect): generic value tree + Reflect.serialize/apply + JSON bridge (#44)
A self-describing Value node (null/int/fixed/bool/str/list/object) with
constructors, builders (Value.add/put) and accessors (get/at/count/kind/
as_int/as_str/…). Reflect.serialize(entity) walks an entity's whole component
set into a value tree — one member per component, each a sub-object of its
fields — and Reflect.apply(entity, value) writes one back; a fixed field
becomes a fixed node, everything else an int node, so the round-trip is
bit-exact, with the model id under "@kind". Json.encode/parse bridge the tree
to and from compact, stable, diffable text, with fixed written as an exact
terminating decimal that parses back bit-for-bit (verified across the raw
Q16.16 range). Together: a one-call, bit-exact save/load for entities.

Written in Ludic and spliced on demand (runtime/native/value.ludic +
reflect_io.ludic, like Query/Light), so a program that doesn't touch
Value.*/Json.*/Reflect.serialize compiles byte-identically and the C-free
bootstrap fixpoint holds (verified). The general tagged-union/any language type
stays tracked in #1; this ships the concrete value tree the serializer needs.

Adds 21 namespace-method docs pages + Value/Json sections,
examples/library/serialize.ludic, and a regression case. Whole CI set green:
x test 72/72, x test-tools 30/30, check-impl/vocabulary/docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-31 15:19:21 +03:00

309 lines
18 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# test.ludic — the top-level regression suite. Replaces test.sh. The compiler is
# written in Ludic (selfhost/) and built from a checked-in LLVM-IR seed with
# clang; there is no C compiler in the repo. This suite proves the compiler
# builds from the seed, compiles every example to its byte-exact golden render,
# self-hosts to a fixpoint, and rebuilds itself with no C compiler.
#
# It drives the other commands through `bin/x <sub>` exactly where test.sh used
# to spawn a sibling script, so each stage stays an independent, observable run.
# compile a feature example as a game, run it (optional keys on stdin) and match
# its stdout against the expected space-joined output.
function feat_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path)
if not game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_e_{nm}`) {
bad2(label, capture_line("grep -i error /tmp/x_gb.err | head -1")); return
}
var got = ""
if (keys == "") { got = capture_line(`/tmp/x_e_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | /tmp/x_e_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a "does it still compile" smoke test (parse -> lower -> link), no run
function qsmoke(path: pointer) -> void {
let nm = flat(path)
if game_build_ok("bin/ludicc", `examples/{path}.ludic`, `/tmp/x_q_{nm}`) {
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(path, capture_line("grep -i error /tmp/x_gb.err | head -1")) }
}
# a plain compile-only rot guard for a showcase example that has no self-asserting
# `entry` (so net_case can't drive it): prove it still parses, lowers and links.
function smoke(path: pointer) -> void {
let nm = flat(path)
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o /tmp/x_sm_{nm}.ll > /tmp/x_sm_{nm}.err 2>&1`) {
bad2(path, capture_line(`tail -1 /tmp/x_sm_{nm}.err`)); return
}
ok(`{path} still compiles`)
}
# a pure-Ludic example that drives and asserts itself: compile headless to IR,
# let clang assemble it, run, and compare stdout. No C host and no C compiled.
function net_case(path: pointer, exp: pointer) -> void {
let nm = flat(path)
let ll = `/tmp/x_n_{nm}.ll`
let log = `/tmp/x_n_{nm}.out`
if not shq(`bin/ludicc --headless examples/{path}.ludic --emit-llvm -o {ll} > {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
if not shq(`{cc()} -O2 {ll} -o /tmp/x_nb_{nm} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
let got = capture_line(`/tmp/x_nb_{nm} < /dev/null`)
if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) }
}
# a test spec (issue #12): `test "name" { expect… }` blocks with no `entry`. The
# compiler synthesises a runner @main, so we just build, run, and require a clean
# exit (0) plus the expected `== N passed, M failed ==` summary on the last line.
function spec_case(path: pointer, exp: pointer) -> void {
let nm = flat(path)
let ll = `/tmp/x_spec_{nm}.ll`
let er = `/tmp/x_spec_{nm}.err`
if not shq(`bin/ludicc examples/{path}.ludic > {ll} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
if not shq(`{cc()} -O2 {ll} -o /tmp/x_spec_{nm} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
let rc = sh(`/tmp/x_spec_{nm} > /tmp/x_spec_{nm}.out 2>&1`)
let summary = capture_line(`tail -1 /tmp/x_spec_{nm}.out`)
if (rc == 0) and (summary == exp) { ok(`{path} ({summary})`) }
else { bad2(path, `rc={string(rc)} last=[{summary}]`) }
}
# issue #8: the failure path — a panic must abort with a non-zero exit and a
# `file:line: panic: <msg>` line on stderr (stdout is unaffected up to the panic).
function panic_case() -> void {
write_file("/tmp/x_panic.ludic", "program P { entry { print(7); panic(\"boom\") } }\n")
if not shq("bin/ludicc /tmp/x_panic.ludic > /tmp/x_panic.ll 2>/dev/null") { bad("panic example did not compile"); return }
if not shq(`{cc()} -O2 /tmp/x_panic.ll -o /tmp/x_panic 2>/dev/null`) { bad("panic example did not link"); return }
let rc = sh("/tmp/x_panic > /tmp/x_panic.out 2>/tmp/x_panic.err")
let msg = capture_line("cat /tmp/x_panic.err")
if (rc != 0) and shq("grep -q 'panic: boom' /tmp/x_panic.err") { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
}
# issue #45: `bin/x test --coverage`. Compile each test-spec with `--coverage`,
# run it with LUDIC_COVERAGE pointed at a per-file dump, then aggregate the dumps
# into a clean per-file line-coverage report. The instrumentation is flag-gated,
# so this reuses the same seed-built bin/ludicc the rest of the suite does.
function cov_one(path: pointer, dir: pointer) -> int {
let nm = flat(path)
let ll = `/tmp/x_cov_{nm}.ll`
let bin = `/tmp/x_cov_{nm}`
let cov = `{dir}/{nm}.cov`
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>/tmp/x_cov.err`) {
bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1
}
if not shq(`{cc()} -O2 {ll} -o {bin} 2>/tmp/x_cov.err`) {
bad2(path, capture_line("tail -1 /tmp/x_cov.err")); return 1
}
run(`rm -f {ll}`)
# run the spec; its atexit hook writes the dump to $LUDIC_COVERAGE
if not shq(`LUDIC_COVERAGE={cov} {bin} > /dev/null 2>&1`) {
bad2(path, "spec exited non-zero"); return 1
}
if not file_exists(cov) { bad2(path, "no coverage dump written"); return 1 }
# aggregate: total instrumented lines, how many were hit, and which were missed.
# each row is `<line> <hits>`, so an unhit line ends in " 0" — grep counts and
# lists them (avoiding awk, whose braces collide with string interpolation).
let total = str_to_int(capture_line(`grep -c '^[0-9]' {cov}`))
let nmiss = str_to_int(capture_line(`grep -c ' 0$' {cov}`))
let covered = total - nmiss
let missed = capture_line(`grep ' 0$' {cov} | cut -d' ' -f1 | tr '\n' ' '`)
var pct = 100
if total > 0 { pct = (covered * 100) / total }
var line = ` examples/{path}.ludic {string(covered)}/{string(total)} lines {string(pct)}%`
if not (missed == "") { line = `{line} uncovered: {missed}` }
print(line)
COV_COVERED = COV_COVERED + covered
COV_TOTAL = COV_TOTAL + total
return 0
}
var COV_COVERED: int = 0
var COV_TOTAL: int = 0
function cmd_test_coverage() -> int {
# the suite normally rebuilds bin/ludicc from the seed; do the same here so the
# instrumentation path is exactly what a clean checkout ships.
run("mkdir -p bin build /tmp/x_cov_out")
if (not is_exec("bin/ludicc")) or newer("selfhost/ludicc.seed.ll", "bin/ludicc") {
if not shq(`{cc()} selfhost/ludicc.seed.ll -o bin/ludicc`) { err("x: cannot build bin/ludicc from the seed\n"); return 1 }
}
COV_COVERED = 0
COV_TOTAL = 0
print("== line coverage (bin/x test --coverage) ==")
cov_one("library/coverage", "/tmp/x_cov_out")
cov_one("library/testing", "/tmp/x_cov_out")
var pct = 100
if COV_TOTAL > 0 { pct = (COV_COVERED * 100) / COV_TOTAL }
print(` ----`)
print(` total: {string(COV_COVERED)}/{string(COV_TOTAL)} lines {string(pct)}%`)
return 0
}
function cmd_test() -> int {
PASS = 0
FAIL = 0
print("== the compiler is written in Ludic, not C ==")
if shq("test -e compiler") or shq("ls compiler/*.c >/dev/null 2>&1") {
bad("compiler/ C sources still present")
} else { ok("no C compiler in the repo (the compiler is selfhost/*.ludic)") }
let cleft = capture_line("find tools -name '*.c' 2>/dev/null | wc -l")
ok(`the only C in the tree is any editor tooling ({cleft} .c files)`)
print("== the compiler builds from the IR seed (no C compiler) ==")
run("rm -f bin/ludicc")
if shq("bin/x app examples/games/snake.ludic --headless >/dev/null 2>&1") and is_exec("bin/ludicc") {
ok("clang selfhost/ludicc.seed.ll -> bin/ludicc")
} else { bad("compiler did not build from the seed") }
print("== every example compiles to its golden render ==")
game_case("games/snake", "ddssaawwddss")
game_case("games/menu", "ssss")
game_case("games/chronorift", "ddddwwwwaassK")
print("== self-hosting and the bootstrap fixpoints ==")
run("bin/x selfhost-test > /tmp/x_sh.out 2>&1")
if shq("grep -q 'gen2.ll == gen3.ll' /tmp/x_sh.out") { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq("grep -q 'no C compiler' /tmp/x_sh.out") { ok("the compiler rebuilds itself from the seed, C-free") } else { bad("C-free bootstrap") }
let corr = capture_line("grep -c PASS /tmp/x_sh.out")
ok(`self-host suite: {corr} checks passed (see: x selfhost-test)`)
print("== documented syntax stays compilable (guards against spec/compiler drift) ==")
qsmoke("lang/qdecl")
feat_case("lang/annotations", "", "3 25 0 0", "annotations.ludic (@Queries / @Computed / @OnSpawn / @Handles)")
feat_case("lang/lifecycle", "", "1 700 50 950 2", "lifecycle.ludic (@OnStart/@OnAttach/@OnSpawn/@OnDespawn/@OnQuit in order)")
feat_case("lang/toggle", "", "6 0 7 1 0", "toggle.ludic (enable/disable + @OnDisable/@OnEnable across property/model)")
feat_case("lang/strings", "", "1 2 3 4 5 6 7 8 9", "strings.ludic (str ops, interpolation, slicing)")
feat_case("lang/rng_demo", "", "69 89 6 -1 0", "rng_demo.ludic (Random.value/int/sign)")
feat_case("lang/time_demo", "aaaaaa", "0 1 2 3 4 16", "time_demo.ludic (Time.frame/elapsed/delta)")
feat_case("lang/scenes", "aaaaaaaaaa", "1000 1 101 102 2 3 900 201 900 202 900", "scenes.ludic (scene/layer/on enter/on exit/become)")
feat_case("lang/detach", "", "15 1 25 0", "detach.ludic (attach/detach + @OnAttach/@OnDetach)")
feat_case("lang/reason", "", "503 1009", "reason.ludic (@OnDespawn reason: Despawned vs Quit)")
# standard-library namespaces, each a self-contained `entry` program asserted
# against known-answer vectors (crypto) or structural invariants (uuid/noise).
feat_case("library/crypto", "", "1 2 3 4 5 6 7 8 9", "crypto.ludic (Crypto SHA-256/HMAC/base64 KAT + CSPRNG shape)")
feat_case("library/uuid", "", "1 2 3 4 5 6 7 8 9 10", "uuid.ludic (Uuid v4/v7 format, version/variant, parse/equals)")
feat_case("library/noise", "", "1 2 3 4 5 6 7 8 9 10 11", "noise.ludic (Noise value/perlin/simplex/fbm/cellular determinism + range)")
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
feat_case("library/anim", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34", "anim.ludic (Anim frame/once/pingpong/cell + Tween progress/loop/yoyo/ease/number/round/point/tint)")
feat_case("library/query", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "query.ludic (Query count/first/nearest/within — ECS spatial queries over the reflection ABI)")
feat_case("library/reflect", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "reflect.ludic (Reflect prop/field enumeration + type + get/set/has/kind — runtime reflection over the world schema)")
feat_case("library/serialize", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "serialize.ludic (Value tree + Json encode/parse + Reflect.serialize/apply — bit-exact save/load; issue #44)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
spec_case("library/testing", "== 6 passed, 0 failed ==")
spec_case("library/coverage", "== 3 passed, 0 failed ==")
feat_case("library/errors", "", "5 10 0 7 1", "errors.ludic (assert guards an invariant, holds -> runs to the end; issue #8 success path)")
feat_case("library/recover", "", "2600 100 1 1 42 7", "recover.ludic (try/else recovers a result value + fallback, error binding, is_ok/is_err; issue #46)")
panic_case()
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)")
# Os known-folders/arch and Fs.list read the BSD utsname/dirent layout, so
# their asserted values are macOS-specific; skip off Darwin (see is_darwin).
if is_darwin() {
feat_case("library/os", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "os.ludic (Os args/env round-trip, platform/arch, known dirs)")
} else { skip("os.ludic (Os known-folder / uname ABI is macOS/BSD-specific)") }
feat_case("library/unicode", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "unicode.ludic (UTF-8 len/validate/char_at/chars/case/truncate/graphemes)")
if is_darwin() {
feat_case("library/fs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32", "fs.ludic (Path join/dir/base/ext/normalize, Fs read/write/list/copy, Mime of/sniff)")
} else { skip("fs.ludic (Fs.list reads the macOS/BSD dirent layout)") }
# issue #9: the Time/Date/Duration/Clock stdlib, driven from its own `entry`.
net_case("lang/offline_rewards", "13 650 2026-08-30 0")
# EV0b + NETWORKING N0–N6: each a self-contained pure-Ludic program, driven and
# asserted from its own `entry`. The transport is the compiler's built-in
# loopback, so a networked game runs with zero foreign code.
net_case("events/mod_events", "10 32 42")
net_case("networking/net_echo", "4 10 20 30 42")
net_case("networking/net_snapshot", "50 7 50")
net_case("networking/net_sync", "12 3 4 50 999")
net_case("networking/net_owner", "-1 7 0 1")
net_case("networking/net_rpc", "0 8")
net_case("networking/net_roles", "1 102")
net_case("networking/net_demo", "5 999 5")
feat_case("events/promote", "", "100 101 200 201", "promote.ludic (@Public -> model_<M>_spawn/despawn events)")
feat_case("events/cancel", "", "0 1 92", "cancel.ludic (cancellable event + cancel + emit-as-expression)")
# EV2 the world table: the mod reflection ABI, callable from Ludic by name.
net_case("ecs/world_get", "50 1 7")
net_case("ecs/world_scan", "2 110")
net_case("ecs/world_spawn", "1 42 1")
net_case("ecs/world_mixed", "99")
net_case("ecs/world_query", "2 110")
feat_case("events/recurse", "", "16", "recurse.ludic (EV6: re-entrant emit is depth-bounded, no runaway cycle)")
net_case("events/scoped", "2")
net_case("ecs/world_dyn", "0 1 30 100 1 30")
feat_case("events/prop_events", "", "300 400", "prop_events.ludic (@Public -> prop_<P>_attach/detach events)")
feat_case("events/scene_events", "", "10 1 20 2 30 3", "scene_events.ludic (public scene -> scene_<S>_enter/exit events)")
feat_case("events/program_events", "", "1 100 2 200", "program_events.ludic (@Public @OnStart/@OnQuit -> program_start/quit)")
feat_case("events/layer_events", "aaaa", "50 2 1 50", "layer_events.ludic (layer toggle + public layer -> layer_<L>_show/hide)")
# showcase examples with no self-asserting `entry` — compile-only so they can't
# silently rot after a compiler or reorg change.
print("== showcase examples still compile ==")
smoke("ecs/hello")
smoke("events/events")
smoke("networking/net_rt")
print("== self-hosted front-end binaries (ludicc / ludic) ==")
# the two commands are one multi-call native binary built from the seed with
# clang alone. ludicc compiles; ludic compiles-and-runs.
if shq("bin/x build-cli > /tmp/x_cli.out 2>&1") and is_exec("bin/ludicc") and is_exec("bin/ludic") {
ok("x build-cli builds bin/ludicc and bin/ludic from the seed (clang-only)")
} else { bad2("x build-cli", capture_line("tail -1 /tmp/x_cli.out")) }
# ludicc -o drives clang to a native binary. A game links windowed by default
# (LUDIC_HOME points the linker at runtime/native/cocoa.ll) — that path is
# macOS-only (Cocoa), so off Darwin we prove the same "-o yields a runnable
# native binary" with a headless link, which needs no window backend.
if is_darwin() {
if shq("LUDIC_HOME=. bin/ludicc examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("file /tmp/x_cli_snake 2>/dev/null | grep -q Mach-O") {
ok("ludicc app.ludic -o bin -> native executable")
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
} else {
if shq("LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o /tmp/x_cli_snake > /tmp/x_cli.out 2>&1") and shq("test -x /tmp/x_cli_snake") {
ok("ludicc app.ludic -o bin -> native executable (headless)")
} else { bad2("ludicc -o", capture_line("tail -1 /tmp/x_cli.out")) }
}
# --emit-llvm stops at the IR.
if shq("bin/ludicc examples/games/snake.ludic --emit-llvm -o /tmp/x_cli_snake.ll > /tmp/x_cli.out 2>&1") and shq("head -1 /tmp/x_cli_snake.ll | grep -q 'LLVM IR'") {
ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line("tail -1 /tmp/x_cli.out")) }
# ludic compiles, runs, and forwards the program's exit code.
write_file("/tmp/x_cli_exit.ludic", "entry { exit(42) }\n")
let rc = sh("LUDIC_HOME=. bin/ludic /tmp/x_cli_exit.ludic > /tmp/x_cli.out 2>&1")
if (rc == 42) { ok("ludic app.ludic -> compiles, runs, forwards exit code") }
else { bad2("ludic run: expected exit 42", `got {string(rc)}`) }
# docs site generator + guard, in Ludic (no Python). docs-gen emits the whole
# pages payload; docs-check is its coverage/integrity guard; docs-palette is
# the named-colour source of truth (its output is the tracked emit_color.ludic
# + palette.json, so a clean git diff proves it stayed byte-identical).
print("== docs site (Ludic generator, no Python) ==")
if shq("bin/x docs-gen --out /tmp/x_docs > /tmp/x_docs.out 2>&1") {
ok("x docs-gen -> the pages-branch payload")
} else { bad2("x docs-gen", capture_line("tail -1 /tmp/x_docs.out")) }
if shq("bin/x docs-check /tmp/x_docs > /tmp/x_docs_chk.out 2>&1") {
ok("x docs-check passes on the generated site")
} else { bad2("x docs-check", capture_line("tail -1 /tmp/x_docs_chk.out")) }
if shq("bin/x docs-palette > /dev/null 2>&1") and shq("git diff --quiet selfhost/backend/stdlib/emit_color.ludic docs/language/colors/palette.json") {
ok("x docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad("x docs-palette drifted (git diff not empty)") }
return report()
}