ludic/tools/ludic-cli/test.ludic

1664 lines
142 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/ludic <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_dir()}/e_{nm}`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/e_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/e_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# an example the compiler must REFUSE, with `want` in the message
function reject_case(path: pointer, want: pointer, label: pointer) -> void {
let ll = `{tmp_dir()}/rj_{flat(path)}.ll`
if shq(`bin/ludicc examples/{path}.ludic --emit-llvm -o {ll} 2>{tmp_dir()}/rj.err`) {
bad2(label, "compiled, and should have been refused")
return
}
let got = capture_line(`grep -i error {tmp_dir()}/rj.err | head -1`)
if s_contains(got, want) { ok(label) } else { bad2(label, `refused, but said [{got}]`) }
}
# LUDIC_VIS_REPORT=1: a program the module rules refuse builds, and says why on stderr
function vis_report_case(path: pointer, want: pointer, label: pointer) -> void {
let ll = `{tmp_dir()}/vr_{flat(path)}.ll`
if not shq(`LUDIC_VIS_REPORT=1 bin/ludicc examples/{path}.ludic --emit-llvm -o {ll} 2>{tmp_dir()}/vr.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/vr.err | head -1`))
return
}
let got = capture(`cat {tmp_dir()}/vr.err`)
if s_contains(got, want) { ok(label) } else { bad2(label, `said [{s_trim(got)}]`) }
}
# a program the checker refuses for exactly `n` reasons, all of them reported in one run
function reject_count(path: pointer, n: int, label: pointer) -> void {
let ll = `{tmp_dir()}/rj_{flat(path)}.ll`
if shq(`bin/ludicc examples/{path}.ludic --emit-llvm -o {ll} 2>{tmp_dir()}/rj.err`) {
bad2(label, "compiled, and should have been refused")
return
}
let got = capture_line(`grep -c ': error:' {tmp_dir()}/rj.err`)
if (got == string(n)) { ok(label) } else { bad2(label, `reported {got} error(s), not {n}`) }
}
# --emit-schema: the example's schema holds `want` (a line of its JSON, exactly as written)
function schema_case(path: pointer, want: pointer, label: pointer) -> void {
let js = `{tmp_dir()}/sc_{flat(path)}.json`
if not shq(`bin/ludicc examples/{path}.ludic --emit-schema {js} 2>{tmp_dir()}/sc.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/sc.err | head -1`))
return
}
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `the schema has no [{want}]`) }
}
# @PerMap: --check reads the example's maps and finds them clean; --no-maps leaves a broken map alone;
# a @Unit spelled another way is a warning naming the canonical one
function permap_check_case() -> void {
let lbl = "--check: every map of lang/permap is clean, --no-maps skips a broken one, @Unit(\"°\") warns \"deg\""
let js = `{tmp_dir()}/dj_permap.json`
if not shq(`bin/ludicc examples/lang/permap/permap.ludic --check --diagnostics=json > {js} 2>/dev/null`) { bad2(lbl, capture_line(`head -3 {js}`)); return }
if not shq(`bin/ludicc examples/rejected/permap_maps/permap_maps.ludic --check --no-maps > /dev/null 2>&1`) { bad2(lbl, "--no-maps still read the maps"); return }
if not shq(`bin/ludicc examples/rejected/permap_unit.ludic --check --diagnostics=json > {js} 2>/dev/null`) { bad2(lbl, "a unit spelling was refused, not warned"); return }
# the JSON escapes the message's quotes (\"°\"), so the part without them is what is looked for
if not s_contains(capture(`cat {js}`), "the unit is spelled") or not s_contains(capture(`cat {js}`), "deg") { bad2(lbl, capture_line(`head -3 {js}`)); return }
ok(lbl)
}
# phase 26: a template checks clean against en.po, its one line of words a warning, and ludic deps
# counts it as english_left
function i18n_check_case() -> void {
let lbl = "--check: a component's template against en.po - clean, english_left 0 in ludic deps"
let js = `{tmp_dir()}/dj_i18n.json`
if not shq(`bin/ludicc examples/lang/i18n/i18n_ui.ludic --check --diagnostics=json > {js} 2>/dev/null`) { bad2(lbl, capture_line(`head -3 {js}`)); return }
let got = capture(`cat {js}`)
if s_contains(got, "\"error\"") { bad2(lbl, capture_line(`head -3 {js}`)); return }
let dp = capture(`bin/ludic deps examples/lang/i18n/i18n_ui.ludic 2>&1`)
if not s_contains(dp, "english_left: 0") { bad2(lbl, `ludic deps said [{s_trim(dp)}]`); return }
ok(lbl)
}
# --check --diagnostics=json: refused, with exactly `n` ERRORS on stdout (warnings beside them are not
# counted), one of the diagnostics holding `want`
function diag_json_case(path: pointer, n: int, want: pointer, label: pointer) -> void {
let js = `{tmp_dir()}/dj_{flat(path)}.json`
if shq(`bin/ludicc examples/{path}.ludic --check --diagnostics=json > {js} 2>/dev/null`) {
bad2(label, "checked clean, and should have been refused")
return
}
let got = capture_line(`grep -c '"severity": "error"' {js}`)
if not (got == string(n)) { bad2(label, `reported {got} error(s), not {n}`); return }
if s_contains(capture(`cat {js}`), want) { ok(label) } else { bad2(label, `no diagnostic holds [{want}]`) }
}
# --stdin-file <path>: stdin stands for <path> wherever the program opens it. A buffer that breaks an
# import reached through its barrel (spelt with `..`) is refused at the buffer's line under the file's
# usual name; a buffer that fixes a refused entry checks clean; a path nothing opens is a warning.
function stdin_file_cases() -> void {
let lbl = "--stdin-file: an unsaved buffer checked in place of its file"
let fish = "examples/modules/kinds/fishing/index.ludic"
let js = `{tmp_dir()}/sf_break.json`
let spelt = "./examples/modules/kinds/fishing/../fishing/index.ludic"
if shq(`sed 's/return fishing_st.landed/return "seven"/' {fish} | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file {spelt} > {js} 2>/dev/null`) {
bad2(lbl, "a buffer with an error checked clean"); return
}
let broke = capture(`cat {js}`)
if not s_contains(broke, `"file": "{fish}", "line": 21`) { bad2(lbl, `no error at the buffer's line 21 of {fish}: {s_trim(broke)}`); return }
let js2 = `{tmp_dir()}/sf_fix.json`
let fix = "sed -e 's/print(on + 1)/print(1)/' -e 's/half(1, 2)/half(1)/' -e 's/-> int { return \"seven\" }/-> int { return 7 }/'"
let clean = shq(`{fix} examples/rejected/every_error.ludic | bin/ludicc examples/rejected/every_error.ludic --check --diagnostics=json --stdin-file examples/rejected/every_error.ludic > {js2} 2>/dev/null`)
let said = s_trim(capture(`cat {js2}`))
if not clean or not (said == "[]") { bad2(lbl, `a buffer that fixes the saved file's errors said {said}`); return }
let js3 = `{tmp_dir()}/sf_none.json`
shq(`printf '' | bin/ludicc examples/modules/private_kinds.ludic --check --diagnostics=json --stdin-file examples/nowhere.ludic > {js3} 2>/dev/null`)
if not s_contains(capture(`cat {js3}`), "the program does not reach this file") { bad2(lbl, "a path the program never opens was not reported"); return }
ok(lbl)
}
# `ludic fmt` for editors (R9): run from a directory under the project, `--lint --json` and `-` find
# the nearest package.ludic upwards and judge a buffer as the file --stdin-name names, relative to it
function fmt_editor_cases() -> void {
let lbl = "ludic fmt --lint --json and ludic fmt - from inside a project"
let p = `{tmp_dir()}/fmt_ed`
let here = capture_line("pwd")
shell(`rm -rf {p} && mkdir -p {p}/src/deep`)
write_file(`{p}/package.ludic`, "package \"t\"\nlint one_statement\nlint paths \"src\"\n")
write_file(`{p}/src/deep/a.ludic`, "function f() -> int {\n let a = 1; return a\n}\n")
let js = `{p}/out.json`
if shq(`cd {p}/src/deep && {here}/bin/ludic fmt --lint --json > {js} 2>/dev/null`) { bad2(lbl, "--lint --json passed a file breaking one_statement"); return }
let said = capture(`cat {js}`)
if not s_contains(said, `"file": "src/deep/a.ludic", "line": 2, "col": 15`) { bad2(lbl, `--lint --json said {s_trim(said)}`); return }
let one = capture(`cd {p}/src/deep && {here}/bin/ludic fmt - --lint --json --stdin-name a.ludic < a.ludic 2>/dev/null`)
if not s_contains(one, `"file": "a.ludic", "line": 2`) { bad2(lbl, `a buffer linted as src/deep/a.ludic said {s_trim(one)}`); return }
let out = capture(`cd {p}/src/deep && printf 'function g() -> int{{return 2}}\n' | {here}/bin/ludic fmt - --stdin-name {p}/src/deep/b.ludic`)
if not (s_trim(out) == "function g() -> int { return 2 }") { bad2(lbl, `ludic fmt - wrote {s_trim(out)}`); return }
if shq(`printf '{{\n' | {here}/bin/ludic fmt - > /dev/null 2>&1`) { bad2(lbl, "ludic fmt - accepted an unclosed brace"); return }
if shq(`{here}/bin/ludic fmt --json > /dev/null 2>&1`) { bad2(lbl, "--json without --lint was accepted"); return }
ok(lbl)
}
# a feature example that consumes an in-repo controller package (packages/): same
# as feat_case, but the compiler is pointed at packages/ via LUDIC_MODULES so a
# `import "ludic.foo/bar.ludic"` resolves, and LUDIC_HOME anchors the engine
# runtime. This is exactly how `ludic build` / an external project builds against a
# fetched source package (the do_import module-root fallback, #63).
function controller_case(path: pointer, keys: pointer, exp: pointer, label: pointer) -> void {
let nm = flat(path)
let root = capture_line("pwd")
let ll = `{tmp_dir()}/c_{nm}.ll`
if not shq(`LUDIC_HOME={root} LUDIC_MODULES={root}/packages bin/ludicc examples/{path}.ludic > {ll} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
if not shq(`{cc()} -O2 {ll} -o {tmp_dir()}/c_{nm} 2>{tmp_dir()}/gb.err`) {
bad2(label, capture_line(`grep -i error {tmp_dir()}/gb.err | head -1`)); return
}
var got = ""
if (keys == "") { got = capture_line(`{tmp_dir()}/c_{nm} < /dev/null`) }
else { got = capture_line(`printf '{keys}' | {tmp_dir()}/c_{nm}`) }
if (got == exp) { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# #75 — an external project (its CWD is not the toolchain repo, with no runtime/
# under it and no ludic_modules) builds an ECS game by resolving the engine runtime
# from $LUDIC_HOME (the toolchain install), independent of the package module root.
function runtime_home_case() -> void {
let root = capture_line("pwd")
let dir = `{tmp_dir()}/ext75`
shq(`rm -rf {dir} && mkdir -p {dir}`)
write_file(`{dir}/game.ludic`, "program Ext {\n property Mob { hp: int = 0 }\n model M { Mob }\n entry {\n spawn M { Mob { hp: 7 } }\n let pm = World.prop_id(\"Mob\")\n let e = World.query_next(pm, 0)\n print(World.get(e, pm, World.field_id(pm, \"hp\")))\n }\n}\n")
let lbl = "runtime ships with the toolchain, resolved from LUDIC_HOME (#75)"
# compiled from the external CWD; there is deliberately no runtime/ or
# ludic_modules/ under {dir}, so the engine runtime can only come from LUDIC_HOME.
if not shq(`cd {dir} && LUDIC_HOME={root} {root}/bin/ludicc game.ludic -o {dir}/game 2>{dir}/err`) {
bad2(lbl, capture_line(`grep -i 'error|cannot open' {dir}/err | head -1`)); return
}
let got = capture_line(`{dir}/game < /dev/null`)
if (got == "7") { ok(lbl) } else { bad2(lbl, `got [{got}] want [7]`) }
}
# The install layout: bin/ beside runtime/ and packages/, reached through $PATH
# from an unrelated directory, with nothing in the environment. This is what
# `curl https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh` lays down and the only shape most
# users will ever run, so it is asserted rather than assumed: a staged copy of
# the toolchain compiles and runs a project created by `ludic new`.
function install_layout_case() -> void {
let root = capture_line("pwd")
let home = `{tmp_dir()}/install`
let lbl = "an installed layout (bin/ + runtime/ + packages/, found on PATH) builds a new project"
shell(`rm -rf {home} && mkdir -p {home}/bin`)
shell(`cp bin/ludic bin/ludicc bin/ludic-fmt {home}/bin/`)
shell(`cp -R runtime {home}/runtime && cp -R packages {home}/packages && cp VERSION {home}/VERSION`)
let work = `{tmp_dir()}/installwork`
shell(`rm -rf {work} && mkdir -p {work}`)
# PATH only — no LUDIC_HOME, and the current directory has no bin/ of its own.
let envp = `env -u LUDIC_HOME PATH={home}/bin:/usr/bin:/bin:/usr/sbin:/sbin`
if not shq(`cd {work} && {envp} ludic new demo > {work}/new.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/new.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic build --headless > {work}/build.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/build.out`)); return
}
if not shq(`cd {work}/demo && {envp} ludic test > {work}/test.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/test.out`)); return
}
# from the project directory, never the repo root: `ludic version` used to
# answer from a ./bin and ./VERSION next to the *current* directory, which made
# this check pass in the repo while a real install said "(version unknown)".
let ver = capture_line(`cd {work}/demo && {envp} ludic version`)
if ver != `ludic {s_trim(read_file("VERSION"))}` {
bad2("an installed `ludic version` reports the install's VERSION", `got [{ver}]`); return
}
# and the shipped CLI carries none of the contributor tasks: `ludic dev …` is
# not a command there, it is a pointer at the tool that has them.
let devlog = `{work}/dev.out`
let devrc = sh(`cd {work}/demo && {envp} ludic dev build > {devlog} 2>&1`)
var devout = read_file(devlog)
if devout == null { devout = "" }
if devrc == 0 or not s_contains(devout, "ludic-dev") {
bad2("an installed `ludic` must not carry the toolchain's own tasks", `rc {string(devrc)}, said [{s_trim(devout)}]`)
return
}
ok(lbl)
}
# `ludic new` must scaffold something that compiles, for any name a user might
# reasonably pick. `my-game` produced `program My-Game`, which is a subtraction,
# and `2048` produced an identifier starting with a digit — both scaffolded a
# project that failed on the very first `ludic run`.
# The asset pack, end to end: `ludic pack` writes it, the runtime mounts it
# before main from packs.index, and the game reads its assets out of it while
# running from a directory where none of them exist on disk. That last part is
# the whole feature - a built game used to run only from its project root.
function pack_roundtrip_case() -> void {
let lbl = "ludic pack -> a game reads its assets with none of them on disk"
let work = `{tmp_dir()}/packrt`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}/assets/sub {work}/ship`)
write_file(`{work}/assets/a.txt`, "alpha")
write_file(`{work}/assets/sub/b.txt`, "beta")
write_file(`{work}/game.ludic`, pack_probe_src())
if not shq(`cd {work} && {root}/bin/ludic pack --out ship/game.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --verify ship/game.lpak > verify.out 2>&1`) {
bad2(lbl, "the pack it just wrote does not verify"); return
}
if not shq(`cd {work} && {root}/bin/ludicc game.ludic -o ship/probe > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
write_file(`{work}/ship/packs.index`, "pack game.lpak" + nl())
# the assets exist only inside the pack from here on
shell(`rm -rf {work}/assets`)
# run from somewhere with no relation to the project at all
let got = s_trim(capture(`cd / && {work}/ship/probe 2>&1`))
if got != "alpha|beta|1|5|0" {
bad2(lbl, `read back [{got}], wanted [alpha|beta|1|5|0]`); return
}
ok(lbl)
}
# the probe game: reads two packed files and asks after a third that is in no
# pack, so a false hit would show up as loudly as a miss
function pack_probe_src() -> pointer {
var s = "program PackProbe {" + nl()
s = s + " entry {" + nl()
s = s + " var a = Fs.read_text(\"assets/a.txt\")" + nl()
s = s + " var b = Fs.read_text(\"assets/sub/b.txt\")" + nl()
s = s + " if a == null { a = \"MISS\" }" + nl()
s = s + " if b == null { b = \"MISS\" }" + nl()
s = s + " print(`{a}|{b}|{string(Fs.exists(\"assets/a.txt\"))}|{string(Fs.size(\"assets/a.txt\"))}|{string(Fs.exists(\"assets/gone.txt\"))}`)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
# App.set_icon: the Dock tile for a binary with no bundle around it. The tile itself
# cannot be asserted from a test process, so what is checked is the part that can go
# wrong silently - that it compiles and runs in a windowed build, that a missing or
# unreadable image is a no-op rather than a crash or a cleared icon, and that a headless
# build (which has no AppKit linked at all) compiles it away to nothing.
function app_icon_case() -> void {
let lbl = "App.set_icon / window_hide / window_show -> windowed no-ops without a window, nothing headless"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/appicon`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}`)
# a real PNG to set, made the same way the bundle test makes its icon
shell(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/icon.png > /dev/null 2>&1`)
var src = "program IconSet {" + nl()
src = src + " entry {" + nl()
src = src + " App.set_icon(\"icon.png\")" + nl()
src = src + " App.set_icon(\"nope.png\")" + nl() # missing: must not crash
src = src + " App.window_hide()" + nl() # no window yet: both are no-ops
src = src + " App.window_show()" + nl()
src = src + " print(\"ok\")" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/i.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc i.ludic -o i > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let got = s_trim(capture(`cd {work} && ./i 2>&1`))
if got != "ok" { bad2(lbl, `windowed build printed [{got}]`); return }
# headless links no AppKit: it has to compile and run just the same
if not shq(`cd {work} && {root}/bin/ludicc i.ludic --headless -o ih > hh.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/hh.out`)); return
}
let goth = s_trim(capture(`cd {work} && ./ih 2>&1`))
if goth != "ok" { bad2(lbl, `headless build printed [{goth}]`); return }
ok(lbl)
}
# Os.save_dir / config_dir / cache_dir have to name the directory the PLATFORM keeps
# those things in - that is the entire reason a program calls them instead of building a
# path. They were the macOS layout on every platform, so a game built on Linux wrote its
# saves to ~/Library/Application Support, which is a directory that means nothing there.
#
# The assert is the host's own convention, so a macOS dev box covers the Apple branch and
# CI covers XDG. The env cases matter as much as the defaults: XDG_* must be honoured
# where it applies and ignored where it does not, and a variable that is set but EMPTY
# falls back, which is what the spec says and what an exported-but-unset variable looks
# like from a shell.
function os_dirs_case() -> void {
let lbl = "Os.save_dir / config_dir / cache_dir follow the platform's own convention"
let work = `{tmp_dir()}/osdirs`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}`)
var src = "program OsDirs {" + nl()
src = src + " entry {" + nl()
src = src + " print(`{Os.save_dir(\"App X\")}|{Os.config_dir(\"App X\")}|{Os.cache_dir(\"App X\")}`)" + nl()
src = src + " }" + nl()
src = src + "}" + nl()
write_file(`{work}/d.ludic`, src)
if not shq(`cd {work} && {root}/bin/ludicc d.ludic -o d > cc.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/cc.out`)); return
}
let home = capture_line("printf %s \"$HOME\"")
# with every XDG variable set: honoured on Linux, irrelevant on macOS
let set_env = "XDG_DATA_HOME=/x/d XDG_CONFIG_HOME=/x/c XDG_CACHE_HOME=/x/k"
let with_xdg = s_trim(capture(`cd {work} && env {set_env} ./d`))
# with none of them set, and with them set but empty
let no_xdg = s_trim(capture(`cd {work} && env -u XDG_DATA_HOME -u XDG_CONFIG_HOME -u XDG_CACHE_HOME ./d`))
let empty_xdg = s_trim(capture(`cd {work} && env XDG_DATA_HOME= XDG_CONFIG_HOME= XDG_CACHE_HOME= ./d`))
var want_plain = `{home}/.local/share/App X|{home}/.config/App X|{home}/.cache/App X`
var want_set = "/x/d/App X|/x/c/App X|/x/k/App X"
if is_darwin() {
# Apple keeps saves and config together; XDG has no standing here at all
want_plain = `{home}/Library/Application Support/App X|{home}/Library/Application Support/App X|{home}/Library/Caches/App X`
want_set = want_plain
}
if no_xdg != want_plain { bad2(lbl, `no XDG: got [{no_xdg}], wanted [{want_plain}]`); return }
if with_xdg != want_set { bad2(lbl, `XDG set: got [{with_xdg}], wanted [{want_set}]`); return }
if empty_xdg != want_plain { bad2(lbl, `XDG empty: got [{empty_xdg}], wanted [{want_plain}]`); return }
ok(lbl)
}
# `.packignore` — the pack's exclude file, with gitignore's semantics. The feature it
# exists for is that a pack root is packed wholesale: the preview renders, bake
# intermediates and .blend files a model pipeline leaves beside its meshes all shipped,
# and the only alternative the manifest offered was naming every file by hand.
#
# The patterns below are one of each kind that people actually rely on, and the assert
# is the exact set that comes out - both halves matter, because a rule that excludes too
# much is as bad as one that excludes nothing and is just as quiet.
function packignore_case() -> void {
let lbl = "ludic pack -> .packignore excludes, with gitignore's rules"
let work = `{tmp_dir()}/packign`
let root = capture_line("pwd")
shell(`rm -rf {work}`)
shell(`mkdir -p {work}/assets/kit/preview {work}/assets/deep/a/tmp {work}/assets/scratch {work}/assets/sub/scratch {work}/assets/keep`)
write_file(`{work}/assets/kit/preview/x.png`, "x") # preview/ - a directory, any depth
write_file(`{work}/assets/kit/model.blend`, "x") # *.blend - a name, any depth
write_file(`{work}/assets/kit/hiker.png`, "x") # kept
write_file(`{work}/assets/deep/a/tmp/junk.txt`, "x") # **/tmp
write_file(`{work}/assets/deep/a/keep.txt`, "x") # kept
write_file(`{work}/assets/scratch/s.txt`, "x") # /assets/scratch - anchored
write_file(`{work}/assets/sub/scratch/keep.txt`, "x") # kept: the anchor does not reach here
write_file(`{work}/assets/keep/other.png`, "x") # assets/keep/*.png
write_file(`{work}/assets/keep/important.png`, "x") # kept: re-included by !
write_file(`{work}/assets/preview`, "x") # kept: a FILE, and the rule says directory
var ig = "# what nobody opens" + nl()
ig = ig + "preview/" + nl() + "*.blend" + nl() + "/assets/scratch" + nl() + "**/tmp" + nl()
ig = ig + "assets/keep/*.png" + nl() + "!assets/keep/important.png" + nl()
write_file(`{work}/.packignore`, ig)
if not shq(`cd {work} && {root}/bin/ludic pack --out o.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
# cut, not awk: `--list` prints "<size>\t<name>" and awk's braces collide with string
# interpolation. capture_line joins the lines with spaces, which is the comparison.
let got = capture_line(`cd {work} && {root}/bin/ludic pack --list o.lpak | tail -n +2 | cut -f2 | LC_ALL=C sort`)
let want = "assets/deep/a/keep.txt assets/keep/important.png assets/kit/hiker.png assets/preview assets/sub/scratch/keep.txt"
if got != want {
bad2(lbl, `packed [{got}], wanted [{want}]`); return
}
# a deeper .packignore wins over a shallower one, which is what makes a kit able to
# keep something the project excludes everywhere else
write_file(`{work}/assets/kit/.packignore`, "!*.blend" + nl())
if not shq(`cd {work} && {root}/bin/ludic pack --out o2.lpak assets > pack2.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack2.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q 'assets/kit/model.blend'`) {
bad2(lbl, "a nested !*.blend did not win over the root rule"); return
}
# and an ignore file is never itself an asset, whatever the rules say
if shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q packignore`) {
bad2(lbl, ".packignore was packed"); return
}
# --no-ignore is the escape hatch: everything, minus the control files
if not shq(`cd {work} && {root}/bin/ludic pack --no-ignore --out o3.lpak assets > pack3.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack3.out`)); return
}
let n3 = capture_line(`cd {work} && {root}/bin/ludic pack --list o3.lpak | tail -n +2 | wc -l | tr -d ' '`)
if n3 != "10" {
bad2(lbl, `--no-ignore packed {n3} files, wanted 10`); return
}
ok(lbl)
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
# app's own assets go in the pack beside it, and the display name is allowed a
# space - which is where the shell quoting in the bundler earns its keep.
function bundle_case() -> void {
let lbl = "ludic bundle -> a .app with metadata, icon, assets and a signature"
if not is_darwin() { skip(lbl); return }
let work = `{tmp_dir()}/bundle`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}/src {work}/assets`)
write_file(`{work}/assets/hello.txt`, "packed")
# a 64x64 source icon: small, but sips will scale it and iconutil accept it
shell(`sips -s format png --resampleHeightWidth 64 64 /System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/GenericApplicationIcon.icns --out {work}/assets/icon.png > /dev/null 2>&1`)
write_file(`{work}/package.ludic`, bundle_manifest_src())
write_file(`{work}/src/main.ludic`, bundle_probe_src())
if not shq(`cd {work} && {root}/bin/ludic bundle > bundle.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/bundle.out`)); return
}
let app = `{work}/build/Probe App.app`
# quoted: the display name has a space in it, which is the whole point, and
# the prelude's file_exists builds an unquoted `test -e`
if not there(`{app}/Contents/MacOS/Probe App`) { bad2(lbl, "no executable in Contents/MacOS"); return }
if not there(`{app}/Contents/Resources/game.lpak`) { bad2(lbl, "no asset pack"); return }
if not there(`{app}/Contents/Resources/packs.index`) { bad2(lbl, "no packs.index"); return }
if not there(`{app}/Contents/Resources/AppIcon.icns`) { bad2(lbl, "no icon"); return }
if not there(`{app}/Contents/PkgInfo`) { bad2(lbl, "no PkgInfo"); return }
if not shq(`plutil -lint "{app}/Contents/Info.plist" > /dev/null 2>&1`) {
bad2(lbl, "Info.plist does not parse"); return
}
# the identifier is derived from the package path when the manifest omits it
let id = capture_line(`plutil -extract CFBundleIdentifier raw "{app}/Contents/Info.plist" 2>/dev/null`)
if id != "io.workshopsoft.probe-app" { bad2(lbl, `bundle id came out [{id}]`); return }
# `app env` is what Launch Services starts it with (a Finder or `open` start)
let env = capture_line(`plutil -extract LSEnvironment.MallocLargeCache raw "{app}/Contents/Info.plist" 2>/dev/null`)
if env != "0" { bad2(lbl, `LSEnvironment.MallocLargeCache came out [{env}]`); return }
if not shq(`codesign --verify "{app}" > /dev/null 2>&1`) { bad2(lbl, "the signature does not verify"); return }
# and it runs from a directory with none of its assets, reading them from the pack
let got = s_trim(capture(`cd / && "{app}/Contents/MacOS/Probe App" 2>&1`))
if got != "packed" { bad2(lbl, `the bundled app printed [{got}], wanted [packed]`); return }
ok(lbl)
}
# `test -e` with the path quoted, so a bundle whose name contains a space can
# be inspected at all
function there(path: pointer) -> bool { return shq(`test -e "{path}"`) }
function bundle_manifest_src() -> pointer {
var s = "package \"git.workshopsoft.io/workshopsoft/probe-app\"" + nl()
s = s + "version \"2.1.0\"" + nl()
s = s + "kind source" + nl()
s = s + "app name \"Probe App\"" + nl()
s = s + "app icon \"assets/icon.png\"" + nl()
s = s + "app copyright \"(c) 2026 A & B\"" + nl()
s = s + "app env \"MallocLargeCache=0\"" + nl()
s = s + "pack \"assets\"" + nl()
return s
}
function bundle_probe_src() -> pointer {
var s = "program Probe {" + nl()
s = s + " entry {" + nl()
s = s + " var t = Fs.read_text(\"assets/hello.txt\")" + nl()
s = s + " if t == null { t = \"MISS\" }" + nl()
s = s + " print(t)" + nl()
s = s + " }" + nl()
s = s + "}" + nl()
return s
}
function scaffold_names_case() -> void {
let lbl = "ludic new scaffolds a project that compiles (hyphens, digits, dots)"
let work = `{tmp_dir()}/scaffold`
shell(`rm -rf {work} && mkdir -p {work}`)
let root = capture_line("pwd")
let names = new []pointer
push(names, "my-game")
push(names, "2048")
push(names, "a.b.c")
push(names, "UPPER_case")
var i = 0
while i < len(names) {
let nm = names[i]
i += 1
if not shq(`cd {work} && {root}/bin/ludic new {nm} > {work}/new.out 2>&1`) {
bad2(lbl, `ludic new {nm} failed`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic build --headless > {work}/build.out 2>&1`) {
let why = capture_line(`tail -1 {work}/build.out`)
bad2(lbl, `{nm}: {why}`); return
}
if not shq(`cd {work}/{nm} && {root}/bin/ludic test > {work}/test.out 2>&1`) {
bad2(lbl, `{nm}: tests failed`); return
}
}
# and a name that cannot be a directory or a package is refused, not mangled
if shq(`cd {work} && {root}/bin/ludic new 'two words' > {work}/bad.out 2>&1`) {
bad2(lbl, "a name with a space was accepted"); return
}
ok(lbl)
}
# a failed expect_near on floats says the numbers, as floats, at its file:line
function expect_fp_fail_case() -> void {
let lbl = "expect_near on floats fails with the numbers at file:line"
let src = `{tmp_dir()}/expfp.ludic`
write_file(src, "program F {\n numbers float\n test \"off\" {\n expect_near(1.5, 2.0, 0.1)\n }\n}\n")
if not shq(`bin/ludicc {src} -o {tmp_dir()}/expfp > {tmp_dir()}/expfp.err 2>&1`) { bad2(lbl, capture_line(`grep -i error {tmp_dir()}/expfp.err | head -1`)); return }
if shq(`{tmp_dir()}/expfp > {tmp_dir()}/expfp.out 2>&1`) { bad2(lbl, "a failing test passed"); return }
let got = capture_line(`head -1 {tmp_dir()}/expfp.out`)
if got == `{src}:4: expect_near failed (got 1.5, want 2)` { ok(lbl) } else { bad2(lbl, `said [{got}]`) }
}
# ludic.lab: a scene on the plate, headless, three shots as PNGs, in well under a gigabyte
function lab_plate_case() -> void {
let lbl = "ludic.lab: the example's three shots on the plate, as PNGs, under 1 GB"
if not is_darwin() { skip(lbl); return }
let bin = `{tmp_dir()}/lab_plate`
if not shq(`bin/ludic build packages/ludic.lab/example/plate.ludic --headless -o {bin} > {tmp_dir()}/lab.out 2>&1`) { bad2(lbl, capture_line(`grep -i error {tmp_dir()}/lab.out | head -1`)); return }
# from the checkout's root, where the renderer's shaders and the plate are found beside it
let work = `{tmp_dir()}/labrun`
shell(`rm -rf {work} build/lab/probe && mkdir -p {work}`)
if not shq(`yes '' | head -300 | /usr/bin/time -l {bin} > {work}/run.out 2> {work}/time.out`) { bad2(lbl, capture_line(`tail -1 {work}/run.out`)); return }
var n = 0
let names = ["front", "side", "above"]
for i in 0 .. 3 {
if shq(`file build/lab/probe/{names[i]}.png | grep -q 'PNG image data, 1920 x 1080'`) { n += 1 }
}
let last = capture_line(`tail -1 {work}/run.out`)
if n != 3 { bad2(lbl, `{string(n)} of 3 shots are PNGs: {last}`); return }
let pl = s_trim(capture_line(`grep 'peak memory footprint' {work}/time.out`))
var k = 0
while k < len(pl) and pl[k] >= '0' and pl[k] <= '9' { k += 1 }
let peak = pl[0 .. k]
if len(peak) >= 10 { bad2(lbl, `peak memory footprint {peak} bytes`); return }
ok(`{lbl} (peak {peak} bytes)`)
}
# ludic deps: the module graph as the compiler resolved it, its numbers, a baseline check and --dot
function deps_case() -> void {
let lbl = "ludic deps: the numbers, --check against a baseline, --dot, --writes"
let p = "examples/modules/tangle.ludic"
let got = capture(`bin/ludic deps {p} 2>&1`)
let want = "modules: 3\ndependencies: 2\nlargest_cycle: 2\ncross_writes: 1\nglobals_written_from_outside: 1\nwidest_function: 1\nwidest_reach: 1\nwidest_write_reach: 1\nframe_allocs: 0\nframe_keeps: 0\nbirth_leaks: 0\nresource_drops: 0\nowned_leaks: 0\nenglish_left: 0\nin the largest cycle: a b\nthe widest function: a_run (examples/modules/tangle/a/index.ludic:3), 1 state\nthe widest write reach: a_run (examples/modules/tangle/a/index.ludic:3), 1 state it can come to change\n"
if not (s_trim(got) == s_trim(want)) { bad2(lbl, `said [{s_trim(got)}]`); return }
let base = `{tmp_dir()}/deps-base.txt`
if not shq(`bin/ludic deps {p} --baseline {base} > /dev/null`) { bad2(lbl, "--baseline failed"); return }
if not shq(`bin/ludic deps {p} --check {base} > /dev/null`) { bad2(lbl, "--check failed against its own baseline"); return }
write_file(base, "dependencies 1\nlargest_cycle 2\ncross_writes 1\nglobals_written_from_outside 1\n")
if shq(`bin/ludic deps {p} --check {base} > /dev/null`) { bad2(lbl, "--check passed with more dependencies than the baseline"); return }
# 0.R2: the widest function is a ratchet too
write_file(base, "dependencies 2\nlargest_cycle 2\ncross_writes 1\nglobals_written_from_outside 1\nwidest_function 0\n")
if shq(`bin/ludic deps {p} --check {base} > {tmp_dir()}/wide.out`) { bad2(lbl, "--check passed with a wider function than the baseline"); return }
if not shq(`grep -q 'widest_function 1 (baseline 0) WORSE' {tmp_dir()}/wide.out`) { bad2(lbl, capture(`cat {tmp_dir()}/wide.out`)); return }
let pk = capture(`bin/ludic deps examples/actions/pack.ludic 2>&1`)
if not s_contains(pk, "the widest function: entry (examples/actions/pack.ludic:29), 2 states") { bad2(lbl, `widest: [{s_trim(pk)}]`); return }
if not shq(`bin/ludic deps {p} --dot | grep -q '"a" -> "b"'`) { bad2(lbl, "--dot has no a -> b edge"); return }
if not shq(`bin/ludic deps {p} --writes | grep -q 'b b_count a examples/modules/tangle/a/index.ludic:4'`) { bad2(lbl, "--writes misses a's write to b_count"); return }
if not shq(`bin/ludic deps {p} --writes | grep -q 'warning: b b_rec a examples/modules/tangle/a/index.ludic:12 through the local r'`) { bad2(lbl, "--writes misses the write through a local"); return }
let lay = capture(`bin/ludic deps examples/modules/layers.ludic 2>&1`)
if not s_contains(lay, "largest_cycle: 1") or not s_contains(lay, "layers: app (2: hud menu)") or not s_contains(lay, "counting the layers' own edges: 2") { bad2(lbl, `layers: [{s_trim(lay)}]`); return }
ok(lbl)
}
# --check: the module rules, the ports and the types, with nothing emitted - a broken uses line is
# refused as a full build refuses it, and a sound program writes no file
function check_build_case() -> void {
let lbl = "ludicc --check / ludic build --check: every check a build makes (binds, names, registries too), nothing written"
let bad = ["rejected/uses_missing", "rejected/layer_reach", "rejected/private_module", "rejected/port_unbound", "rejected/registry_hidden", "rejected/port_new", "rejected/wrong_arity", "rejected/bind_missing_fn", "rejected/unknown_name", "rejected/registry_count_key"]
let want = ["fishing uses items.inv_add", "hud uses items.item_count", "add is private to module bank", "port Clock is used here but never bound", "Tools is private to module kit", "Clock is a port", "leaves out h", "fn no_such_clock: no function called no_such_clock", "unknown identifier no_such_name", "its constant would be TL_COUNT, which is the registry's size"]
for i in 0 .. len(bad) {
if shq(`bin/ludicc --check examples/{bad[i]}.ludic > {tmp_dir()}/chk.err 2>&1`) { bad2(lbl, `{bad[i]} passed the check`); return }
let said = capture(`cat {tmp_dir()}/chk.err`)
if not s_contains(said, want[i]) { bad2(lbl, `{bad[i]}: [{s_trim(said)}]`); return }
}
shell("rm -f build/checked_out")
if not shq(`bin/ludic build examples/modules/layers.ludic --check -o build/checked_out > {tmp_dir()}/chk.out 2>&1`) { bad2(lbl, capture_line(`tail -1 {tmp_dir()}/chk.out`)); return }
if file_exists("build/checked_out") { bad2(lbl, "--check wrote a binary"); return }
ok(lbl)
}
# ludic migrate state: a program with module-level vars comes out with a state, threaded, and runs
# the same
function migrate_case() -> void {
let lbl = "ludic migrate state: vars into a state, parameters threaded, the same output"
let work = `{tmp_dir()}/migrate`
shell(`rm -rf {work} && mkdir -p {work}`)
var src = "program Demo {\n var count: int = 0\n var names: []string = new []string # the names\n var title: string = \"names\"\n"
src = src + " function add(n: string) -> void {\n push(names, n)\n count += 1\n }\n"
src = src + " function total() -> int {\n return count\n }\n"
src = src + " function report() -> string {\n return `{total()} {title}, first {names[0]}`\n }\n"
src = src + " entry {\n add(\"a\")\n add(\"b\")\n print(report())\n }\n}\n"
write_file(`{work}/demo.ludic`, src)
if not shq(`bin/ludic migrate state {work}/demo.ludic > {work}/out.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out.txt`)); return }
let got = read_file(`{work}/demo.ludic`)
if not s_contains(got, "state DemoState {") or not s_contains(got, "function add(demo_st: mut DemoState, n: string)") or not s_contains(got, "function total(demo_st: DemoState)") or not s_contains(got, "entry (demo_st: mut DemoState)") or not s_contains(got, "let title: string") {
bad2(lbl, `rewrote it as [{got}]`)
return
}
if not shq(`bin/ludicc {work}/demo.ludic -o {work}/demo > {work}/cc.txt 2>&1`) { bad2(lbl, capture_line(`grep -i error {work}/cc.txt | head -1`)); return }
let ran = capture_line(`{work}/demo`)
if ran == "2 names, first a" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# ludic migrate state --prune: a state nothing uses leaves the functions that take it and the calls
# that pass it; an argument for a parameter the callee no longer has goes too (a package's verb that
# dropped a state), a state declared after a plain parameter is kept where it is, and the program
# then builds and runs
function migrate_prune_case() -> void {
let lbl = "ludic migrate state --prune: unused states and the arguments that fill them go, the same output"
let work = `{tmp_dir()}/migprune`
shell(`rm -rf {work} && mkdir -p {work}`)
var src = "program Prune {\n state Log { lines: []string = new []string }\n state Bag { items: []int = new []int }\n"
src = src + " function head<T>(xs: []T) -> T { return xs[0] }\n"
src = src + " function put(b: mut Bag, v: int) -> void { push(b.items, v) }\n"
src = src + " function add(log_st: mut Log, b: mut Bag, v: int) -> void { put(log_st, b, v) }\n"
src = src + " function outer(log_st: mut Log, b: mut Bag) -> void { add(log_st, b, 4) }\n"
src = src + " function top(log_st: Log, b: Bag) -> int { return head(log_st, b.items) }\n"
src = src + " function said(log_st: mut Log, b: Bag) -> int {\n push(log_st.lines, \"x\")\n return late(3, log_st)\n }\n"
src = src + " function late(k: int, log_st: Log) -> int { return len(log_st.lines) + k - 3 }\n"
src = src + " entry (log_st: mut Log, b: mut Bag) {\n outer(log_st, b)\n print(`{top(log_st, b)} {said(log_st, b)}`)\n }\n}\n"
write_file(`{work}/prune.ludic`, src)
if not shq(`bin/ludic migrate state --prune {work}/prune.ludic > {work}/out.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out.txt`)); return }
let got = read_file(`{work}/prune.ludic`)
if not s_contains(got, "function add(b: mut Bag, v: int) -> void { put(b, v) }") or not s_contains(got, "function outer(b: mut Bag) -> void { add(b, 4) }") or not s_contains(got, "function top(b: Bag) -> int { return head(b.items) }") or not s_contains(got, "function said(log_st: mut Log) -> int") or not s_contains(got, "entry (log_st: mut Log, b: mut Bag)") or not s_contains(got, "{top(b)} {said(log_st)}") or not s_contains(got, "function late(k: int, log_st: Log) -> int") or not s_contains(got, "return late(3, log_st)") {
bad2(lbl, `rewrote it as [{got}]`)
return
}
if not shq(`bin/ludicc {work}/prune.ludic -o {work}/prune > {work}/cc.txt 2>&1`) { bad2(lbl, capture_line(`grep -i error {work}/cc.txt | head -1`)); return }
let ran = capture_line(`{work}/prune`)
if ran == "4 1" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# expect_eq on strings: the text is compared, and a failure prints both (the spec fails on purpose)
function expect_str_case() -> void {
let lbl = "testing_strings.ludic (expect_eq compares strings by content, a null only equal to a null, and prints both on a failure)"
let out = `{tmp_dir()}/t_strings`
if not shq(`bin/ludicc examples/library/testing_strings.ludic -o {out} > {out}.log 2>&1`) { bad2(lbl, capture_line(`grep -i error {out}.log | head -1`)); return }
if shq(`{out} > {out}.txt 2>&1`) { bad2(lbl, "two failing tests, and it exited 0"); return }
let got = capture(`cat {out}.txt`)
let want = "ok - equal text built two ways\nexamples/library/testing_strings.ludic:11: expect_eq failed (got \"camp\", want \"lake\")\nFAIL - different text fails, and says both\nexamples/library/testing_strings.ludic:15: expect_eq failed (got \"<null>\", want \"camp\")\nFAIL - a null is not a string\n== 1 passed, 2 failed ==\n"
if s_trim(got) == s_trim(want) { ok(lbl) } else { bad2(lbl, `said [{s_trim(got)}]`) }
}
# 0.R: the drain that calls every reducer is the program's own, so the module that declares the
# actions gains no dependency on the modules that reduce them
function actions_deps_case() -> void {
let lbl = "the action drain is the program's: acts depends on no reducer's module (ludic deps)"
let got = capture(`bin/ludic deps examples/actions/modules.ludic 2>&1`)
if s_contains(got, "largest_cycle: 1") and s_contains(got, "dependencies: 0") and s_contains(got, "the widest function: reducer Bag on PickUp (") { ok(lbl) } else { bad2(lbl, s_trim(got)) }
}
# 0.R4: a step list takes no state and reaches every state its steps take - a ROOT, left out of
# widest_reach and listed on its own line, and what calls it (one) is counted without going through
# it; --widest and --reach list the functions by what they take and by what they reach, a root marked
function deps_reach_case() -> void {
let lbl = "ludic deps: the reach through fn values, the roots apart, --widest N and --reach N"
let got = capture(`bin/ludic deps examples/state/reach.ludic 2>&1`)
if not s_contains(got, "widest_reach: 1") or not s_contains(got, "roots, left out of the reaches (they walk a table of fn values; the rest are counted without going through one): 1, the widest run_steps (examples/state/reach.ludic:10), 2 states") { bad2(lbl, s_trim(got)); return }
let top = capture(`bin/ludic deps examples/state/reach.ludic --reach 2 2>&1`)
if not s_contains(top, " 1 3 3 one (examples/state/reach.ludic:13)\n 0 2 2 run_steps (examples/state/reach.ludic:10) (root)") { bad2(lbl, s_trim(top)); return }
let wide = capture(`bin/ludic deps examples/state/reach.ludic --widest 1 2>&1`)
if s_contains(wide, "takes reaches changes function") and s_contains(wide, "one (examples/state/reach.ludic:13)") and not s_contains(wide, "step_a") { ok(lbl) } else { bad2(lbl, s_trim(wide)) }
}
# 0.R5: what a function can come to CHANGE; a port member and a registry field reach only themselves
function deps_wreach_case() -> void {
let lbl = "ludic deps: --wreach, a question through a port or a registry field changes nothing"
let top = capture(`bin/ludic deps examples/state/write_reach.ludic --wreach 10 2>&1`)
if not s_contains(top, " 0 2 2 changes (examples/state/write_reach.ludic:27)") or not s_contains(top, " 0 1 0 asks (examples/state/write_reach.ludic:22)") or s_contains(top, "asks_local") { bad2(lbl, s_trim(top)); return }
ok(lbl)
}
# a program built headless with the toolchain and run: its first line
function headless_case(path: pointer, exp: pointer, label: pointer) -> void {
let out = `{tmp_dir()}/h_{flat(path)}`
if not shq(`bin/ludicc --headless examples/{path}.ludic -o {out} > {out}.log 2>&1`) { bad2(label, capture_line(`grep -i error {out}.log | head -1`)); return }
let got = capture_line(`{out} < /dev/null`)
if got == exp { ok(label) } else { bad2(label, `got [{got}] want [{exp}]`) }
}
# a program built headless whose verdict is one of its lines (the renderer logs before it)
function headless_line_case(path: pointer, exp: pointer, label: pointer) -> void {
let out = `{tmp_dir()}/h_{flat(path)}`
if not shq(`bin/ludicc --headless examples/{path}.ludic -o {out} > {out}.log 2>&1`) { bad2(label, capture_line(`grep -i error {out}.log | head -1`)); return }
let got = capture_line(`{out} < /dev/null 2>&1 | grep -c '^{exp}$'`)
if got == "1" { ok(label) } else { bad2(label, capture_line(`{out} < /dev/null 2>&1 | grep -i -E 'fail|cannot' | head -1`)) }
}
# ludic migrate state and components: the header names what every member needs, a field read in a
# member is not edited, a module named like a package keeps a state of its own, and the program
# runs as it did
function migrate_component_case() -> void {
let lbl = "ludic migrate state: a component's header, a module named like a package, the same output"
let work = `{tmp_dir()}/migcomp`
shell(`rm -rf {work} && mkdir -p {work}/game/parts {work}/game/fishing && cp examples/state/component_parts/Tally.xml {work}/game/parts/`)
write_file(`{work}/game/parts/Tally.ludic`, "component Tally {\n prop label: string\n total: int = points\n shown: string = `{prefix}{total}`\n function big() -> bool { return total > 7 }\n on add(n: int) {\n points += n\n if big() { bigs += 1 }\n fish_cast()\n }\n}\n")
write_file(`{work}/game/fishing/index.ludic`, "module fishing\nvar casts: int = 0\nexport function fish_cast() -> void { casts += 1 }\nexport function fish_casts() -> int { return casts }\n")
var m = "import \"ludic.ui\"\nimport \"fishing\"\nprogram Mig {\n numbers float\n var points: int = 0\n var bigs: int = 0\n var prefix: string = \"#\"\n import \"parts/Tally.ludic\"\n"
m = m + " function frame(ui_st: mut UiState) -> UiNode {\n let root: UiNode = ui_nodes(ui_st, \"Tally\", null)\n ui_place(ui_st, root, 0.0, 0.0, 300.0, 200.0)\n return root\n }\n"
m = m + " entry (ui_st: mut UiState) {\n var root = frame(ui_st)\n ui_press(ui_st, root.children[0].children[1])\n root = frame(ui_st)\n ui_press(ui_st, root.children[0].children[1])\n root = frame(ui_st)\n"
m = m + " print(`{root.children[0].children[0].text} {points} {bigs} {fish_casts()}`)\n }\n}\n"
write_file(`{work}/game/main.ludic`, m)
let root = capture_line("pwd")
if not shq(`cd {work} && {root}/bin/ludic migrate state game/main.ludic > out.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out.txt`)); return }
let tally = read_file(`{work}/game/parts/Tally.ludic`)
if not s_contains(tally, "component Tally (fishing_app_st: mut FishingAppState, mig_st: mut MigState) {") or not s_contains(tally, "if big() { mig_st.bigs += 1 }") { bad2(lbl, `rewrote the component as [{tally}]`); return }
if not s_contains(read_file(`{work}/game/fishing/index.ludic`), "export state FishingAppState {") { bad2(lbl, "module fishing's state is not its own"); return }
if not shq(`cd {work} && {root}/bin/ludicc --headless game/main.ludic -o mig > cc.txt 2>&1`) { bad2(lbl, capture_line(`grep -i error {work}/cc.txt | head -1`)); return }
let ran = capture_line(`{work}/mig < /dev/null`)
if ran == " #10 big 10 1 2" { ok(lbl) } else { bad2(lbl, `ran [{ran}]`) }
}
# ludic migrate state never writes outside what it was given: a module a program imports from
# elsewhere that needs changing refuses the whole run, and nothing is written
function migrate_foreign_case() -> void {
let lbl = "ludic migrate state refuses to change a file outside the programs it was given"
let work = `{tmp_dir()}/migforeign`
shell(`rm -rf {work} && mkdir -p {work}/lib/extra {work}/game`)
write_file(`{work}/lib/extra/index.ludic`, "module extra\nvar n: int = 0\nexport function bump() -> void { n += 1 }\n")
let main = "import \"../lib/extra\"\nprogram F {\n entry { bump() }\n}\n"
write_file(`{work}/game/main.ludic`, main)
let root = capture_line("pwd")
if shq(`cd {work} && {root}/bin/ludic migrate state game/main.ludic > out.txt 2>&1`) { bad2(lbl, "it went ahead"); return }
if not shq(`grep -q 'lib/extra/index.ludic' {work}/out.txt`) { bad2(lbl, capture(`cat {work}/out.txt`)); return }
if not (read_file(`{work}/game/main.ludic`) == main) { bad2(lbl, "the program was changed anyway"); return }
ok(lbl)
}
# every package's own tests, the way a package author runs them: `ludic test packages`
function packages_test_case() -> void {
let lbl = "ludic test packages: every package's tests pass"
if shq(`bin/ludic test packages > {tmp_dir()}/pkgs.out 2>&1`) { ok(lbl) }
else { bad2(lbl, capture_line(`grep -m1 FAIL {tmp_dir()}/pkgs.out`)) }
}
# `ludic test <dir>`: the test programs under a package, each test block in a process of its own
# (a global one test changes is fresh in the next), and a failed expect named by file:line
function test_dir_case() -> void {
let lbl = "ludic test <dir>: *_test.ludic and tests/*.ludic, a process per test, expect at file:line"
let work = `{tmp_dir()}/testdir`
let root = capture_line("pwd")
shell(`rm -rf {work} && mkdir -p {work}/pk/tests {work}/pk/src/deep`)
var a = "program State {\n state Tally {\n counter: int = 0\n seen: []int = new []int\n }\n"
a = a + " test \"first\" (t: mut Tally) {\n t.counter += 1\n push(t.seen, 1)\n expect_eq(t.counter, 1)\n expect_eq(len(t.seen), 1)\n }\n"
a = a + " test \"second\" (t: mut Tally) {\n t.counter += 1\n push(t.seen, 2)\n expect_eq(t.counter, 1)\n expect_eq(len(t.seen), 1)\n }\n}\n"
write_file(`{work}/pk/tests/state.ludic`, a)
write_file(`{work}/pk/src/deep/sums_test.ludic`, "program Sums {\n test \"adds\" {\n expect_eq(2 + 2, 4)\n }\n test \"wrong\" {\n expect_eq(2 + 2, 5)\n }\n}\n")
write_file(`{work}/pk/src/helper.ludic`, "program NotATest {\n entry { exit(3) }\n}\n")
if shq(`cd {work} && {root}/bin/ludic test pk > out.txt 2>&1`) { bad2(lbl, "a failing test passed"); return }
let got = capture(`cat {work}/out.txt`)
if not s_contains(got, "pk/src/deep/sums_test.ludic:6: expect_eq failed (got 4, want 5)") { bad2(lbl, `no file:line for the failure in [{s_trim(got)}]`); return }
if not s_contains(got, "pk/tests/state.ludic (2 tests)") { bad2(lbl, `the two tests shared a global: [{s_trim(got)}]`); return }
if s_contains(got, "helper.ludic") { bad2(lbl, "a file that is not a test was run"); return }
if not shq(`cd {work} && {root}/bin/ludic test pk/tests > out2.txt 2>&1`) { bad2(lbl, capture_line(`tail -1 {work}/out2.txt`)); return }
ok(lbl)
# -j: two tests at once, each writing one fixed scratch path - each has a TMPDIR of its own
let lbl2 = "ludic test -j 2: tests run side by side, each with its own Os.temp_dir()"
var t = "program Scratch {\n"
t = t + " function hold(v: string) -> bool {\n let p = Os.temp_dir() + \"/scratch.txt\"\n Fs.write_text(p, v)\n for i in 0 .. 3000 {\n if Fs.read_text(p) != v { return false }\n }\n return true\n }\n"
t = t + " test \"one\" {\n expect(hold(\"one\"))\n }\n test \"two\" {\n expect(hold(\"two\"))\n }\n}\n"
write_file(`{work}/pk/tests/scratch.ludic`, t)
if not shq(`cd {work} && {root}/bin/ludic test pk/tests -j 2 > out3.txt 2>&1`) { bad2(lbl2, capture(`cat {work}/out3.txt`)); return }
if not shq(`grep -q 'pk/tests/scratch.ludic (2 tests)' {work}/out3.txt`) { bad2(lbl2, capture(`cat {work}/out3.txt`)); return }
ok(lbl2)
# a test program holding a phase handler: the runner boots no frame loop and calls no rt_init it lacks
let lbl3 = "ludic test: a program with a phase handler compiles and runs its tests"
write_file(`{work}/pk/tests/handler.ludic`, "program D {\n handler K phase Input { }\n test \"d\" {\n expect_eq(1, 1)\n }\n}\n")
if not shq(`cd {work} && {root}/bin/ludic test pk/tests/handler.ludic > out4.txt 2>&1`) { bad2(lbl3, capture(`cat {work}/out4.txt`)); return }
ok(lbl3)
}
# package.ludic drives the CLI: `entry` (or the one program under src/) is what
# build compiles, scripts run by name, and hooks wrap commands — a failing
# `before` stops the command, `after` runs only on success.
function package_scripts_case() -> void {
let lbl = "package.ludic entry, scripts and hooks"
let work = `{tmp_dir()}/pkgscripts`
shell(`rm -rf {work} && mkdir -p {work}`)
let root = capture_line("pwd")
let L = `{root}/bin/ludic`
if not shq(`cd {work} && {L} new g > /dev/null 2>&1 && mv g/src/main.ludic g/src/game.ludic`) { bad2(lbl, "setup failed"); return }
# no src/main.ludic: the single program under src/ is found
if not shq(`cd {work}/g && {L} build --headless > {work}/b1.out 2>&1`) { bad2(lbl, "the lone src/ program was not found"); return }
# a second program makes it ambiguous; `entry` settles it
write_file(`{work}/g/src/other.ludic`, "program Other {\n entry { }\n}\n")
if shq(`cd {work}/g && {L} build --headless > {work}/b2.out 2>&1`) { bad2(lbl, "two programs and no entry should not build"); return }
var man = read_file(`{work}/g/package.ludic`)
man = man + "entry \"src/game.ludic\"\n"
man = man + "script \"greet\" \"echo hi-$LUDIC_EVENT\"\n"
man = man + "hook before build \"echo pre > hook.log\"\n"
man = man + "hook after build \"echo post >> hook.log\"\n"
man = man + "hook before test \"exit 7\"\n"
write_file(`{work}/g/package.ludic`, man)
if not shq(`cd {work}/g && {L} build --headless > {work}/b3.out 2>&1`) { bad2(lbl, "entry was not honoured"); return }
if not (capture_line(`tr '\n' ' ' < {work}/g/hook.log`) == "pre post") { bad2(lbl, "build hooks did not run in order"); return }
if not (capture_line(`cd {work}/g && {L} greet | tail -1`) == "hi-greet") { bad2(lbl, "a script did not run by name"); return }
if not (capture_line(`cd {work}/g && {L} test > /dev/null 2>&1; echo $?`) == "7") { bad2(lbl, "a failing before hook did not stop the command"); return }
ok(lbl)
}
# 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_dir()}/q_{nm}`) {
ok(`{path} compiles (@Queries desugars to S_QUERY)`)
} else { bad2(path, capture_line(`grep -i error {tmp_dir()}/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_dir()}/sm_{nm}.ll > {tmp_dir()}/sm_{nm}.err 2>&1`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/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_dir()}/n_{nm}.ll`
let log = `{tmp_dir()}/n_{nm}.out`
if not shq(`bin/ludicc{case_unsafe_flag()} --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_dir()}/nb_{nm} >> {log} 2>&1`) {
let t = capture_line(`tail -1 {log}`)
bad2(path, `build ({t})`); return
}
let got = capture_line(`{tmp_dir()}/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_dir()}/spec_{nm}.ll`
let er = `{tmp_dir()}/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_dir()}/spec_{nm} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
let rc = sh(`{tmp_dir()}/spec_{nm} > {tmp_dir()}/spec_{nm}.out 2>&1`)
let summary = capture_line(`tail -1 {tmp_dir()}/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_dir()}/panic.ludic`, "program P { entry { print(7); panic(\"boom\") } }\n")
if not shq(`bin/ludicc {tmp_dir()}/panic.ludic > {tmp_dir()}/panic.ll 2>/dev/null`) { bad("panic example did not compile"); return }
if not shq(`{cc()} -O2 {tmp_dir()}/panic.ll -o {tmp_dir()}/panic 2>/dev/null`) { bad("panic example did not link"); return }
let rc = sh(`{tmp_dir()}/panic > {tmp_dir()}/panic.out 2>{tmp_dir()}/panic.err`)
let msg = capture_line(`cat {tmp_dir()}/panic.err`)
if (rc != 0) and shq(`grep -q 'panic: boom' {tmp_dir()}/panic.err`) { ok(`panic aborts non-zero with a located stderr message ({msg})`) }
else { bad2("panic", `rc={string(rc)} err=[{msg}]`) }
}
# 25.1: the fence fails a frame that keeps memory once play has settled - exit 86, naming the line and
# the kind - and under R3D_ALLOC_FENCE=count runs on and says what was kept
function alloc_fence_unbounded_case() -> void {
let lbl = "alloc_fence_unbounded.ludic (a declared site whose bytes keep rising window after window is declared but unbounded: exit 86, named; count mode runs on and the census says so)"
let b = `{tmp_dir()}/e_alloc_fence_unb`
if not game_build_ok("bin/ludicc", "examples/lang/alloc_fence_unbounded.ludic", b) { bad2(lbl, "did not build"); return }
let rc = sh(`R3D_ALLOC_DWIN=20 R3D_ALLOC_DRISE=4 {b} < /dev/null > {tmp_dir()}/afu.out 2>{tmp_dir()}/afu.err`)
let said = shq(`grep -q 'declared but unbounded' {tmp_dir()}/afu.err`)
let named = shq(`grep -q 'alloc_fence_unbounded.ludic:11 new Row' {tmp_dir()}/afu.err`)
let counted = capture_line(`R3D_ALLOC_FENCE=count R3D_ALLOC_DWIN=20 R3D_ALLOC_DRISE=4 R3D_ALLOC_CENSUS={tmp_dir()}/afu.census {b} < /dev/null 2>/dev/null`)
let census = capture_line(`grep '^unbounded ' {tmp_dir()}/afu.census | cut -d' ' -f2`)
let dsite = shq(`grep -q '^dsite [0-9]* remember ' {tmp_dir()}/afu.census`)
if (rc == 86) and said and named and (counted == "bad 0 rows 2000") and (census == "1") and dsite { ok(lbl) }
else { bad2(lbl, `rc={string(rc)} said={string(said)} named={string(named)} count=[{counted}] census=[{census}] dsite={string(dsite)}`) }
}
function alloc_fence_rewarm_case() -> void {
let lbl = "alloc_fence_rewarm.ludic (memory kept every frame from the start is judged once the warm-up's cap is reached, and Mem.play() again does not push the deadline back: exit 86)"
let b = `{tmp_dir()}/e_alloc_fence_rw`
if not game_build_ok("bin/ludicc", "examples/lang/alloc_fence_rewarm.ludic", b) { bad2(lbl, "did not build"); return }
let rc = sh(`{b} < /dev/null > /dev/null 2>{tmp_dir()}/afr.err`)
let head = capture_line(`grep 'alloc-fence: frame' {tmp_dir()}/afr.err | head -1`)
if (rc == 86) and (head == "alloc-fence: frame 3000 kept +16 B (1 made, 0 freed)") { ok(lbl) } else { bad2(lbl, `rc={string(rc)} head=[{head}]`) }
}
function alloc_fence_sites_case() -> void {
let lbl = "alloc_fence_sites.ludic (two lines take turns keeping memory, one through a runtime helper; each report charges its own line, never the one that allocated last)"
let b = `{tmp_dir()}/e_alloc_fence_sites`
if not game_build_ok("bin/ludicc", "examples/lang/alloc_fence_sites.ludic", b) { bad2(lbl, "did not build"); return }
let e = `{tmp_dir()}/afs.err`
sh(`R3D_ALLOC_FENCE=warn R3D_ALLOC_WARM_MAX=10 {b} < /dev/null > /dev/null 2>{e}`)
let texts = capture_line(`grep -c '+32 B entry examples/lang/alloc_fence_sites.ludic:11 str_repeat' {e}`)
let boxes = capture_line(`grep -c '+16 B entry examples/lang/alloc_fence_sites.ludic:12 new Box' {e}`)
let wrong = capture_line(`grep -c '+32 B entry examples/lang/alloc_fence_sites.ludic:12' {e}`)
if (texts == "25") and (boxes == "25") and (wrong == "0") { ok(lbl) } else { bad2(lbl, `texts={texts} boxes={boxes} charged-to-the-box-line={wrong}`) }
}
function alloc_fence_leak_case() -> void {
let lbl = "alloc_fence_leak.ludic (a frame that keeps a record fails the run with exit 86 and names its line; count mode runs on)"
let b = `{tmp_dir()}/e_alloc_fence_leak`
if not game_build_ok("bin/ludicc", "examples/lang/alloc_fence_leak.ludic", b) { bad2(lbl, "did not build"); return }
let rc = sh(`{b} < /dev/null > {tmp_dir()}/afl.out 2>{tmp_dir()}/afl.err`)
let head = capture_line(`grep 'alloc-fence: frame' {tmp_dir()}/afl.err | head -1`)
let named = shq(`grep -q 'alloc_fence_leak.ludic:11 new Box' {tmp_dir()}/afl.err`)
let counted = capture_line(`R3D_ALLOC_FENCE=count {b} < /dev/null`)
if (rc == 86) and (head == "alloc-fence: frame 50 kept +80 B (2 made, 0 freed)") and named and (counted == "kept 80 bad 1") { ok(lbl) }
else { bad2(lbl, `rc={string(rc)} head=[{head}] named={string(named)} count=[{counted}]`) }
}
# 25.1: with nothing but the frame's mark the fence starts itself, waits out a loading that grows,
# judges once 600 frames in a row kept nothing, and fails the frame that keeps a record; --fence=count
# builds a run that goes on
function alloc_fence_auto_case() -> void {
let lbl = "alloc_fence_auto.ludic (the fence starts at the first frame, judges once play is flat, fails frame 1500; --fence=count runs on)"
let b = `{tmp_dir()}/e_alloc_fence_auto`
if not game_build_ok("bin/ludicc", "examples/lang/alloc_fence_auto.ludic", b) { bad2(lbl, "did not build"); return }
let rc = sh(`{b} < /dev/null > {tmp_dir()}/afa.out 2>{tmp_dir()}/afa.err`)
let head = capture_line(`grep 'alloc-fence: frame' {tmp_dir()}/afa.err | head -1`)
let bc = `{tmp_dir()}/e_alloc_fence_auto_c`
var through = ""
if shq(`bin/ludicc --fence=count examples/lang/alloc_fence_auto.ludic -o {bc} 2>/dev/null`) { through = capture_line(`{bc} < /dev/null`) }
if (rc == 86) and (head == "alloc-fence: frame 1500 kept +16 B (1 made, 0 freed)") and (through == "through") { ok(lbl) }
else { bad2(lbl, `rc={string(rc)} head=[{head}] count=[{through}]`) }
}
# 25.3: a pool that keeps records across frames through a generic push must not take them from the
# frame's scratch - poisoned on every reset it prints what the heap does
function arena_defaults_case() -> void {
let lbl = "arena_defaults.ludic (--arena: a field's default made for a kept record and a frame's temporary stays on the heap for both)"
let b = `{tmp_dir()}/e_arena_defaults`
if not shq(`bin/ludicc --arena examples/lang/arena_defaults.ludic -o {b} 2>/dev/null`) { bad2(lbl, "did not build"); return }
let poisoned = capture_line(`R3D_ARENA_CHECK=1 R3D_ALLOC_FENCE=off {b} < /dev/null`)
if (poisoned == "497 124747") { ok(lbl) } else { bad2(lbl, `poisoned [{poisoned}]`) }
}
function arena_fnval_case() -> void {
let lbl = "arena_fnval.ludic (--arena: what a function called through a function value returns is kept; a pool holding it survives the resets)"
let b = `{tmp_dir()}/e_arena_fnval`
if not shq(`bin/ludicc --arena examples/lang/arena_fnval.ludic -o {b} 2>/dev/null`) { bad2(lbl, "did not build"); return }
let poisoned = capture_line(`R3D_ARENA_CHECK=1 R3D_ALLOC_FENCE=off {b} < /dev/null`)
if (poisoned == "5 1053") { ok(lbl) } else { bad2(lbl, `poisoned [{poisoned}]`) }
}
function arena_alias_case() -> void {
let lbl = "arena_alias.ludic (--arena: what is pushed through an alias of kept memory (let live = s.cells) is kept; 0xDD poison changes nothing)"
let b = `{tmp_dir()}/e_arena_alias`
if not shq(`bin/ludicc --arena examples/lang/arena_alias.ludic -o {b} 2>/dev/null`) { bad2(lbl, "did not build"); return }
let poisoned = capture_line(`R3D_ARENA_CHECK=1 R3D_ALLOC_FENCE=off {b} < /dev/null`)
if (poisoned == "3 1518") { ok(lbl) } else { bad2(lbl, `poisoned [{poisoned}]`) }
}
function arena_pool_case() -> void {
let lbl = "arena_pool.ludic (--arena: a generic push into a kept pool keeps its records and its buffer on the heap; 0xDD poison changes nothing)"
let b = `{tmp_dir()}/e_arena_pool`
if not shq(`bin/ludicc --arena examples/lang/arena_pool.ludic -o {b} 2>/dev/null`) { bad2(lbl, "did not build"); return }
let poisoned = capture_line(`R3D_ARENA_CHECK=1 R3D_ALLOC_FENCE=off {b} < /dev/null`)
let heap = capture_line(`R3D_ARENA=0 R3D_ALLOC_FENCE=off {b} < /dev/null`)
if (poisoned == "7 3498") and (heap == "7 3498") { ok(lbl) } else { bad2(lbl, `poisoned [{poisoned}] heap [{heap}]`) }
}
# issue #45: `bin/ludic-dev 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_dir()}/cov_{nm}.ll`
let bin = `{tmp_dir()}/cov_{nm}`
let cov = `{dir}/{nm}.cov`
if not shq(`bin/ludicc --coverage examples/{path}.ludic > {ll} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
}
if not shq(`{cc()} -O2 {ll} -o {bin} 2>{tmp_dir()}/cov.err`) {
bad2(path, capture_line(`tail -1 {tmp_dir()}/cov.err`)); return 1
}
shell(`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 += covered
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.
shell(`mkdir -p bin build {tmp_dir()}/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("ludic-dev: cannot build bin/ludicc from the seed\n"); return 1 }
}
COV_COVERED = 0
COV_TOTAL = 0
print("== line coverage (bin/ludic-dev test --coverage) ==")
cov_one("library/coverage", `{tmp_dir()}/cov_out`)
cov_one("library/testing", `{tmp_dir()}/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_dev_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) ==")
shell("rm -f bin/ludicc")
if shq("bin/ludic build 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 ==")
shell(`bin/ludic-dev selfhost-test > {tmp_dir()}/sh.out 2>&1`)
if shq(`grep -q 'gen2.ll == gen3.ll' {tmp_dir()}/sh.out`) { ok("the compiler reproduces itself (fixpoint)") } else { bad("fixpoint") }
if shq(`grep -q 'no C compiler' {tmp_dir()}/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_dir()}/sh.out`)
ok(`self-host suite: {corr} checks passed (see: ludic-dev 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/floats", "", "1 2 3 4 5 6 7 8 9 10 11 12", "floats.ludic (float/double: operators, context-typed literals, conversions, Math)")
feat_case("lang/operators", "", "1 2 3 4 5 6 7 8 9 10", "operators.ludic (compound assignment on fixed/string/long, -fixed, char escapes, list literals)")
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/bignum", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "bignum.ludic (BigInt arbitrary-precision + Decimal exact base-10 money)")
feat_case("library/containers", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "containers.ludic (Dict string-keyed hash map + Set string set)")
feat_case("library/numeric", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "numeric.ludic (Huge idle big-numbers + Angle wrapping radians + Percent clamped [0,1])")
feat_case("library/jobs", "", "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", "jobs.ludic (Job background compute/defer/fulfill/cancel + Promise all/race/progress + Sync mutex/atomic/channel; issue #14)")
feat_case("library/threads", "", "1 2 3 4 5 6 7", "threads.ludic (Job.parallel_for on OS threads + Sync atomic/mutex shared across workers + fn name worker entry)")
feat_case("library/optionresult", "", "1 2 3 4 5 6 7 8 9 10 11 12", "optionresult.ludic (option some/none + result ok/err/try safety types)")
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/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")
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)")
g_case_unsafe = true # it drives the decoders' raw buffers
feat_case("library/tiled_p0", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21", "tiled_p0.ludic (Tiled P0: Xml reader + Base64 decode/encode + gzip framing over inflate; issue #67)")
g_case_unsafe = false
feat_case("library/tiled_p05", "", "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", "tiled_p05.ludic (Tiled P0.5: TMX/TSX reader -> same Value-tree intermediate as TMJ; Kenney sampleMap.tmx; issue #68)")
feat_case("library/tiled_p1", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p1.ludic (Tiled P1: rt_tmap model + GID resolver/flip decode + render; loads+draws Kenney map identically from .tmx and .tmj; issue #69)")
feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)")
feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)")
feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)")
feat_case("library/tiled_p5", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p5.ludic (Tiled P5: image/group layers + iso/hex/staggered coords + Wang GID resolution; issue #73)")
feat_case("library/tiled_p6", "", "1 2 3 4 5 6 7 8 9 10 11 12", "tiled_p6.ludic (Tiled P6: infinite/chunked maps + .world stitching + base64+zstd decompression; issue #74)")
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)")
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")
feat_case("library/physics", "", "184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1", "physics.ludic (Body + Collider + esys_move: gravity/land, wall stop, sub-pixel, entity platform, one-way, triggers — entity broadphase; issue #65)")
feat_case("library/physics_tiles", "", "112 1 80 1 48 1 60", "physics_tiles.ludic (esys_move tile-grid broadphase: tile floor/wall + one-way tile land-from-above / rise-through; issue #65)")
runtime_home_case() # #75: engine runtime resolves from LUDIC_HOME, not the project
# builtin gameplay controllers, consumed as in-repo packages (issues #57-#61)
controller_case("library/core_components", "", "110 30 184 1", "core_components.ludic (ludic.core #77: canonical Position/Body/Collider imported from a package, driving esys_move, composed with a game Health component)")
controller_case("library/sprite_render", "q", "1 0", "sprite_render.ludic (ludic.core #85: engine sprite-render system auto-draws a Sprite entity from its Position; respects hidden)")
controller_case("library/sprite_atlas", "q", "32 1 1 1 0", "sprite_atlas.ludic (#90 Sprite { atlas: 1 } routes esys_sprite through atlas_draw_ex: a 16x32 cell_span draws both cells, tint applies, atlas: 0 stays a table id)")
controller_case("library/world_bounds", "", "184 -16 184 1 0", "world_bounds.ludic (ludic.core #84: engine world-bounds system — clamp/wrap/bounce/kill keep a Body in the play area)")
controller_case("library/gameplay_foundation", "", "2 0 1 5 24 0 2 1 15 85 7 1 1", "gameplay_foundation.ludic (ludic.gameplay #57: Cooldown timer + Stats/modifier stack + Faction table + Combat cancel/mutable pipeline)")
controller_case("games/platformer_demo", "", "1 1 1 1 1 1 1 1", "platformer_demo.ludic (ludic.platformer #58: gravity/land, jump apex, coyote, veto-gated double jump, wall collision + disable-system lever)")
controller_case("games/platformer_scaffolding", "", "1 1 1 1 1 1", "platformer_scaffolding.ludic (ludic.platformer #58 layers 4-5: moving-platform rider carry, pickup->score, spring, hazard+Life i-frames)")
controller_case("games/shooter_demo", "", "1 1 1 1 1 1 1 1 1 1 1", "shooter_demo.ludic (ludic.shooter #60: decoupled move/aim, weapon registry, projectile faction-hit, spread/ring, homing, wave spawner)")
controller_case("games/npcai_demo", "", "1 1 1 1 1 1 1 1", "npcai_demo.ludic (ludic.npcai #61: perception/spot, FSM chase+attack driving the shooter weapon, utility flee, companion follower)")
controller_case("library/prefabs", "", "230159 30 0 7 44 -1 2", "prefabs.ludic (prefab chains, spawn as an expression, Prefab.spawn / spawn_at by name, self() in @OnSpawn)")
controller_case("library/component_access", "xxxxxxxx", "9 15 10 33 1 0 0 1 0 1 101 100 102 1 2 1 0", "component_access.ludic (Prop.of/has/count/despawn_all, countdown fields, enum-typed machine)")
controller_case("library/scene_menus", "xxxxxxxxxxxx", "100 200 201 1 202 300 6 7 8", "scene_menus.ludic (become from a listener, layer @Queries, scene shows / lasts, goto: buttons)")
controller_case("library/managers", "xxxxxxxxxxxx", "35046 46 251 53 900 -1 20 324 0 10 102", "managers.ludic (Map cell API, Sprite move/face/flash/blink, IVec2 geometry, List.sample, Input.move_i, Fx, audio bank, Camera.shake_for)")
controller_case("library/combat_kit", "xxxxxxxxxxxx", "6846 153196 31 107110 6000 5002 1 80 97 11 10 700 110", "combat_kit.ludic (Dash, Melee + MeleeHit, build stats in Combat.damage + Crit, Stats.add, Dungeon arena / exits / entry points, Prefs.max)")
controller_case("games/rpg_demo", "", "1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1", "rpg_demo.ludic (ludic.rpg #59 all 7 modules: grid move+veto+interact, inventory+equip, crafting, quests, dialog graph, sokoban+signal gate, poison status)")
spec_case("library/testing", "== 6 passed, 0 failed ==")
spec_case("library/testing_generic", "== 2 passed, 0 failed ==")
spec_case("library/testing_random", "== 1 passed, 0 failed ==")
spec_case("library/testing_floats", "== 3 passed, 0 failed ==")
expect_str_case()
expect_fp_fail_case()
feat_case("library/random_plain", "", "1 1 1", "random_plain.ludic (Random.* and a bare rng_range in a program with no ECS)")
feat_case("library/value_direct", "", "{\"fish\":3} 3", "value_direct.ludic (value_* / json_* called directly splice the value tree)")
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 14", "os.ludic (Os args/env round-trip, platform/arch, known dirs, pid)")
} 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")
g_case_unsafe = true # the transport ABI takes raw bytes
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")
g_case_unsafe = false
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)")
feat_case("events/answer", "", "10 20 0", "answer.ludic (a listener's return ends that listener, not the dispatch)")
reject_case("rejected/duplicate_const", "'KEY_LEFT' is defined twice", "a const defined twice is refused, naming both places")
reject_case("rejected/duplicate_property", "'Look' is defined twice", "a property defined twice is refused")
reject_case("rejected/engine_alias_name", "rng_range is the engine's Random.range, which this program calls", "a function named like an engine namespace method's target is refused where that method is called")
reject_case("rejected/builtin_name", "run is a built-in of the compiler", "a function named like a compiler built-in (run, exit, free...) is refused")
reject_case("rejected/redeclared_local", "'v0' is declared twice in this block", "a local declared twice in one block is refused")
reject_case("rejected/missing_return", "can reach its end without returning", "a function that can run off its end without its result is refused")
feat_case("functions/values", "", "42 10 25 49 81 1.5 1", "values.ludic (L2: fn types, fn name, calls through a local, global, field, element, parameter, result)")
reject_case("rejected/fn_type_mismatch", "op wants a fn(int)->int and this is a fn(float)->float", "a function of the wrong type is refused")
feat_case("modules/visible", "", "42 42", "visible.ludic (L3: a module's exported function and event used from outside it)")
reject_case("rejected/private_module", "add is private to module bank", "a private function of another module is refused")
feat_case("modules/uses", "", "8 3", "uses.ludic (L3: `module fishing uses base` - a module reaches only the modules it names)")
reject_case("rejected/uses_missing", "fishing uses items.inv_add", "a module that says `uses` cannot reach a module it does not name, exported or not")
reject_case("rejected/uses_cycle", "go round in a circle: a -> b -> a", "a cycle in the declared uses is refused")
feat_case("modules/friend_of", "", "7 7", "friend_of.ludic (L3: `friend module lab of bank` sees bank's private names)")
reject_case("rejected/friend_scope", "add is private to module bank", "a friend of one module is not a friend of another")
feat_case("modules/ports", "", "30 5", "ports.ludic (L3: `port Clock { ... }` in a module, `bind Clock { ... }` in the app, calls through Clock.now())")
reject_case("rejected/port_unbound", "port Clock is used here but never bound", "a port used and never bound is refused at the use")
reject_case("rejected/port_missing", "bind Clock leaves out day, which has no default", "a bind gives every required member")
reject_case("rejected/port_twice", "port Clock is bound twice (first at", "a port is bound once")
feat_case("modules/port_vars", "", "40 45 12", "port_vars.ludic (L3: a port member that takes nothing bound to a variable, money: g_money)")
reject_case("rejected/port_var_args", "only a member that takes nothing can be bound to a variable", "a member with parameters binds a function, not a variable")
reject_case("rejected/port_new", "Clock is a port: it is filled once with 'bind Clock", "a port is bound, not made with new")
reject_case("rejected/port_private", "Clock is private to module clock", "whoever binds a port has to see it")
reject_case("rejected/uses_package", "mech uses ludic_inventory.inv_limit", "a package's module counts under `uses` like any other")
reject_case("rejected/uses_render3d", "mech uses ludic_render3d.PI", "a package with no module line is still named under `uses`")
feat_case("modules/private_twins", "", "105 41 7", "private_twins.ludic (L3: two modules' private seed, dice and LIMIT, and the program's seed, do not collide)")
feat_case("modules/private_kinds", "", "7 hare", "private_kinds.ludic (L3: two modules' private record Catch and event Landed do not collide)")
reject_case("rejected/exported_kinds", "'Catch' is defined twice", "two modules that export one record still clash")
reject_case("rejected/exported_events", "event 'Landed' is defined twice", "two modules that export one event clash")
feat_case("modules/tangle", "", "4", "tangle.ludic (the program ludic deps is tested on)")
feat_case("modules/own_uiact", "", "3", "own_uiact.ludic (0.R4: a game's exported UiAct does not meet ludic.ui's, which is its own module's)")
reject_case("rejected/pkg_export_clash", "ludic_ui exports it, and exported names are one namespace", "a type named like a package's export says whose it is")
reject_case("rejected/exported_twins", "function 'seed' is defined twice", "two modules that export one name still clash")
feat_case("modules/layers", "", "5", "layers.ludic (L3: `module menu in layer app uses items` - one layer's modules use each other and may go round)")
reject_case("rejected/layer_reach", "hud uses items.item_count", "a layered module is still held to its uses outside the layer")
reject_case("rejected/layer_cycle", "go round in a circle: items -> layer app -> items", "a cycle through a layer and out of it is refused")
feat_case("actions/pack", "", "0 1 9 13 picked 7 picked 9 too heavy", "pack.ludic (0.R: actions, reducers - one per state, in the order of the states' names - dispatch, drain_actions, an action a reducer dispatches goes behind)")
controller_case("actions/ui_press", "", "5 10", "ui_press.ludic (0.R4: an action a UI press dispatches is reduced before ludic.ui hands control back)")
feat_case("actions/reads", "", "5 5 1", "reads.ludic (0.R5: a reducer reads other states, declared between its own and the action)")
feat_case("actions/modules", "", "2", "modules.ludic (0.R: an action in one module, its reducer in another)")
actions_deps_case()
deps_reach_case()
deps_wreach_case()
feat_case("state/reach", "", "3", "reach.ludic (0.R4: a step list of fn values supplies each step its states)")
feat_case("actions/phases", "dd a q", "1 0 2 0 2 1 1 1 1 2 1 2", "phases.ludic (0.R: the frame loop drains the queue after every phase - Input's actions are reduced before Update)")
feat_case("actions/runaway", "", "actions: Ping is still being dispatched after 64 rounds of reducers - a reducer dispatches what dispatches it", "runaway.ludic (0.R: a reducer that dispatches what dispatches it is stopped by name)")
reject_case("rejected/registry_count_key", "def Tools count: its constant would be TL_COUNT", "a registry key named count is refused")
reject_case("rejected/reducer_two_states", "a reducer writes one state, and w is a mut Wallet", "a reducer writes exactly one state")
reject_case("rejected/reducer_not_action", "Buy is not an action", "a reducer is on an action")
reject_case("rejected/dispatch_unknown", "dispatch Sell: Sell is not an action", "only an action is dispatched")
feat_case("state/counter", "", "11 3 heard 5", "counter.ludic (0.S: a state, mut and read-only parameters, entry, listener and fn-value injection)")
feat_case("state/port_field", "", "40", "port_field.ludic (0.S: a port member bound to a state's field)")
spec_case("state/tested", "== 2 passed, 0 failed ==")
reject_case("rejected/state_readonly", "c is read-only here (c: Tally)", "a read-only state cannot be assigned through")
reject_case("rejected/state_mut_arg", "bump changes Tally (c: mut Tally), and c is read-only here", "a read-only state is not passed as mut")
reject_case("rejected/module_var", "a module-level var is refused", "a module-level var is refused")
reject_case("rejected/module_let_write", "LIMITS is module-level and immutable all the way down", "a module-level let is immutable all the way down")
reject_case("rejected/mut_not_state", "mut is for a state parameter", "mut is for a state parameter")
reject_case("rejected/state_alias_readonly", "r holds part of a read-only Table; take the state as mut Table", "a reference out of a read-only state is read-only too")
reject_case("rejected/module_let_alias", "l holds part of LIMITS, which is module-level and immutable all the way down", "a module-level let is immutable through a local that holds it")
reject_case("rejected/state_component_ro", "look is read-only here (look: Look)", "a component's state is read-only unless its header says mut")
controller_case("state/component", "", " #10 big 10 1", "component.ludic (0.S: a component's header names its states; its getters, functions and events take them and the template never sees them)")
headless_case("rendering/ui_render3d", "ok", "ui_render3d.ludic (ludic.ui's render3d backend builds against the renderer)")
headless_line_case("rendering/release", "RELEASE OK", "release.ludic (model_release: meshes go, a shared texture stays until its last user goes)")
headless_line_case("rendering/bc", "BC OK", "bc.ludic (a .dds beside a .png arrives BC7-compressed with every mip level)")
headless_line_case("rendering/steady", "STEADY OK", "steady.ludic (a buffer made again and a whole frame leave malloc's live bytes where they were)")
headless_line_case("rendering/chunks", "CHUNKS OK", "chunks.ludic (the ground's B-spline heights a 64 m chunk at a time, edges shared bit for bit)")
migrate_component_case()
migrate_foreign_case()
reject_case("rejected/runtime_type_clash", "PadButton is the runtime's enum", "a program's type named like one of the runtime's is refused")
migrate_case()
migrate_prune_case()
vis_report_case("rejected/uses_missing", "uses: examples/rejected/uses_valley/items/index.ludic:3: items.inv_add used from", "LUDIC_VIS_REPORT=1 lists a uses violation and builds")
feat_case("lang/checked", "", "7.5 3 12 hi 2 ok", "checked.ludic (L4: literals take their slot's kind, string(p), []string as []pointer, null, named args)")
reject_case("rejected/unknown_type", "kind_of's parameter f: there is no type CharFact", "a misspelled type in a parameter is refused where it is written")
reject_case("rejected/unknown_field_type", "Sheet.facts: there is no type CharFact", "a misspelled type in a field is refused where it is written")
reject_case("rejected/param_twice", "add names two parameters n", "a parameter named twice is refused")
reject_case("rejected/wrong_arity", "this call to area leaves out h, which has no default", "a call with the wrong number of arguments is refused")
reject_case("rejected/float_into_int", "metres wants an int and this is a float", "a float computed into an int is refused")
reject_case("rejected/record_mixup", "d wants a Door and this is a Key", "one record type is not another")
reject_case("rejected/record_as_text", "argument 1 of sign wants a string and this is a Trail", "a record is not text")
reject_case("rejected/text_plus_number", "text joins text only", "text plus a number is refused")
reject_case("rejected/math_into_text", "field a1 of Note wants a string and this is an int", "a Math.* result has its type: an int from Math.max is not text")
reject_count("rejected/every_error", 3, "every type error in a program is reported at once, not only the first")
diag_json_case("rejected/every_error", 3, "\"line\": 8, \"col\": 15, \"severity\": \"error\", \"message\": \"half takes 1 argument(s) and this call gives 2\"", "--diagnostics=json: every type error as {file, line, col, severity, message}")
diag_json_case("rejected/ref_unknown", 1, "@Ref(Shops): this program has no Shops - left unresolved", "--diagnostics=json: an @Ref to a registry the program lacks is a warning beside the error")
diag_json_case("rejected/node_bad", 8, "crate.shape is \\\"cube\\\", and field Tool.shape is @OneOf(", "--diagnostics=json: every @Node / @Clip / @Material with no model field, @OneOf of the wrong kind or a row outside its words, and the targets the program lacks as warnings")
stdin_file_cases()
fmt_editor_cases()
feat_case("lang/generics", "", "7 Crater Lake a 2 west 7 0.75 2.5 1", "generics.ludic (L5: generic records and functions, nested instances, fn-typed parameters and fields, inference from the result's slot)")
reject_case("rejected/generic_unbound", "cannot tell what T is in this call to pool_new", "a call whose type arguments nothing decides is refused")
reject_case("rejected/generic_mismatch", "words wants a Pool<string> and this is a Pool<int>", "two instances of one generic are two types")
reject_case("rejected/generic_arity", "Pool takes 1 type argument(s) and Pool<int, string> gives 2", "a generic takes as many type arguments as it has parameters")
reject_case("rejected/private_generic", "grow is private to module kit", "an instance of a private generic is private to its module")
feat_case("lang/long_literals", "", "4294967295 3988292384 -4294967296 1099511627776 -3750763034362895579 -1 1", "long_literals.ludic (a decimal literal past 2^31 - 1, and a hex literal of more than eight digits, is a long)")
reject_case("rejected/long_into_int", "4294967295 does not fit one; it is a long", "a long literal given to an int is refused, not wrapped")
feat_case("lang/runtime_temps", "", "1 2 3 4 5 grew 0 files grew 0", "runtime_temps.ludic (Log, DateTime.format, Path, Mime, Text, Os and Fs keep nothing a call does not hand back)")
feat_case("lang/string_temps", "", "1 2 3 4 5 6 7 grew 0", "string_temps.ludic (a concatenation's pieces, a template's holes and a compared side are freed once used; a kept one is not)")
feat_case("lang/value_list_regrow", "", "bad 0 xs 2", "value_list_regrow.ludic (a model list filled in place that shrinks and grows back keeps its cut-off items as spares: nothing kept, the fence lets it through)")
feat_case("lang/json_free_edited", "", "names Sam|Ada grew 32", "json_free_edited.ludic (Json.free_all after a migration put literals in and a loader set a string: only the parser's strings are freed, a kept copy reads on, a thousand loads keep nothing)")
feat_case("lang/json_saves", "", "1 2 3 saves grew 0", "json_saves.ludic (Json.write_file saves through a kept buffer: the text Json.encode gives, 1000 saves keep nothing)")
feat_case("lang/json_unicode", "", "json unicode 8 of 8", "json_unicode.ludic (Json.parse decodes \\uXXXX to UTF-8, a surrogate pair joined, a lone one or bad hex as U+FFFD, and \\t \\r \\b \\f)")
feat_case("lang/alloc_fence", "", "frames 300 kept 0 bad 0", "alloc_fence.ludic (25.1: a frame that makes only what it frees or reuses keeps nothing, and the fence passes it)")
feat_case("lang/alloc_fence_declared", "", "bad 0 kept 0", "alloc_fence_declared.ludic (25.1: @alloc_ok on a function, a statement and a generic's statement is declared at run time; the failing fence lets it through)")
alloc_fence_leak_case()
alloc_fence_unbounded_case()
alloc_fence_rewarm_case()
alloc_fence_sites_case()
alloc_fence_auto_case()
arena_pool_case()
arena_fnval_case()
arena_alias_case()
feat_case("modules/alloc_ok_private", "", "bad 0 kept 0", "alloc_ok_private.ludic (25.1: @alloc_ok on a module's private function and its private generic's statement is declared at run time)")
arena_defaults_case()
feat_case("lang/nested_templates", "", "outer [inner 3 {x}] \"`}\" end", "nested_templates.ludic (a template literal inside another's hole)")
feat_case("lang/aliases", "", "10 5 3", "aliases.ludic (L6: a namespace method declared as an alias of a function, labelled or by its parameters)")
reject_case("rejected/alias_arity", "this call to Trail.length leaves out to, which has no default", "an alias's arguments are checked against its target")
reject_case("rejected/alias_twice", "Trail.length is declared twice", "a namespace method is declared once")
feat_case("lang/registry", "", "3 Crate true 2 fire_ring -1", "registry.ludic (L8: a registry filled by def declarations - index constants, a count, the key, a lookup)")
reject_case("rejected/registry_unknown", "there is no registry Tables", "a def names a registry that exists")
feat_case("lang/attributes", "", "2 1 Lantern 2", "attributes.ludic (editor attributes: @Ref, @OneOf, @Range, @Unit, @Asset, @Color, @AppendOnly, @ByKey - no effect on the program)")
feat_case("lang/schema_hash", "", "1", "schema_hash.ludic (Build.schema_hash(): FNV-1a 64 of the program's own schema, 0 in a release)")
reject_case("rejected/ref_unknown", "field Tool.seller: @Ref(Vendor) names Vendor, which is not a registry", "@Ref names a registry, not another kind of thing")
reject_case("rejected/node_bad", "field Tool.grip: @Node(handle) names no field", "@Node names a field of its record")
schema_case("lang/attributes", "{\"name\": \"weight\", \"type\": \"float\", \"default\": \"1.0\", \"file\": \"examples/lang/attributes.ludic\", \"line\": 24, \"col\": 33, \"attributes\": [{\"name\": \"Range\", \"args\": [0, 20.5]}, {\"name\": \"Unit\", \"args\": [\"kg\"]}]", "--emit-schema: a field's type, default, place and attributes")
schema_case("lang/attributes", "{\"key\": \"lantern\", \"constant\": \"TL_LANTERN\", \"index\": 1, \"file\": \"examples/lang/data/attr_tools.lres\", \"line\": 3, \"col\": 1", "--emit-schema: a resource entry's constant, index and place")
schema_case("lang/attributes", "\"fn_type\": \"fn(Tool,int)->float\", \"params\": [\"Tool\", \"int\"]", "--emit-schema: a function with arguments, its fn_type spelled as a field's")
schema_case("lang/attributes", "{\"name\": \"Node\", \"args\": [\"tool\"]}", "--emit-schema: @Node and the field it reads its model from")
schema_case("lang/attributes", "{\"name\": \"Ref\", \"args\": [\"Items\"], \"unresolved\": true}", "--emit-schema: an attribute whose target the program lacks is marked unresolved, not refused")
schema_case("library/ui_component", "{\"name\": \"step\", \"type\": \"int\", \"default\": \"1\", \"file\": \"examples/library/ui_component_parts/Counter.ludic\", \"line\": 4, \"col\": 3, \"attributes\": [], \"doc\": \"how much one press adds\"}", "--emit-schema: a component's prop, with its default, place and doc")
schema_case("library/ui_component", "\"xml\": \"examples/library/ui_component_parts/Counter.xml\", \"lss\": \"examples/library/ui_component_parts/Counter.lss\"", "--emit-schema: a component's template and stylesheet")
schema_case("state/component", "\"states_read\": [\"Score\", \"Look\"]", "--emit-schema: the states a component's header names, apart from its model")
schema_case("state/component", "{\"name\": \"add\", \"params\": [[\"n\", \"int\"]], \"line\": 8}", "--emit-schema: a component's event as the template calls it, its states and instance stripped")
schema_case("library/ui_pointer", "{\"tag\": \"chartmap\", \"via\": \"ui_native_input\", \"handler\": \"map_input\", \"module\": null, \"file\": \"examples/library/ui_pointer.ludic\", \"line\": 35, \"col\": 5}", "--emit-schema: a native tag registered by a literal, and its handler")
schema_case("library/ui_pointer", "\"natives\": [\"chartmap\"]}", "--emit-schema: the native tags a component's template uses")
reject_case("rejected/registry_twice", "def Furnishings crate is declared twice", "a registry's key is declared once")
reject_case("rejected/registry_field", "Furnishing has no field colour", "a def's fields are the record's")
feat_case("modules/open_registry", "", "clock animals weather / 2 2 3", "open_registry.ludic (L8+L3: an open registry takes other modules' defs; its own entries first, then by module name)")
reject_case("rejected/registry_closed", "registry Tools is not open to other modules", "a def from another module into a registry that is not open is refused")
reject_case("rejected/registry_hidden", "Tools is private to module kit", "a def goes through visibility: the registry must be exported")
feat_case("modules/registry_extend", "", "3 Rope Pine tea Splint 20", "registry_extend.ludic (L8+L9: `def Recipes from \"recipes.lres\"` fills a package's open registry from the game's file)")
reject_case("rejected/registry_extend_field", "bad_recipes.lres:3: error: field minutes of Recipe wants an int", "a resource file extending a registry is checked at its own line")
feat_case("lang/resources", "", "2 Axe 2 Split 10 0 1", "resources.ludic (L9: a registry read from a resource file at compile time, nested records typed by the schema)")
reject_case("rejected/resource_field", "bad_tools.lres:3: error: field weight of Tool wants a float", "a resource file's entry is checked at its own line")
reject_case("rejected/resource_missing", "cannot read the resource file data/no_such.lres", "a registry's resource file must exist")
feat_case("lang/permap/permap", "", "yes yes 3 7 90 2 -4 fly 5 42 0 9 3 yes 8 / yes 1 -1 0 crate 12 1 0 0 21 same examples/lang/permap/maps/beta/density/a.png / 0 1 2 2 7 1 0 1 1 / -1 1 0 2 3 / no 0 examples/lang/permap/maps/bad/one/props.lres:2:18: PropRow has no field colour", "permap.ludic (@PerMap: rows read per map - constants and fn by name, nested lists, a cross-row @Ref, a reload shrinking in place, chunks in/out/in, a missing chunk empty, a bad file false + file:line:col; built under arena strict)")
permap_check_case()
schema_case("lang/permap/permap", "\"prefix\": null, \"source\": \"instances/{cx}_{cz}.lres\", \"open\": false, \"exported\": false, \"module\": null, \"scope\": \"map\", \"chunk\": 64", "--emit-schema: a @PerMap registry has scope map, its chunk size and no prefix")
schema_case("lang/permap/permap", "{\"name\": \"Ref\", \"args\": [\"Spots\"], \"scope\": \"map\"}", "--emit-schema: an @Ref into a map table says scope map")
schema_case("lang/permap/permap", "\"units\": [\"m\", \"m/s\", \"m/s2\"", "--emit-schema: the canonical @Unit spellings")
reject_case("rejected/permap_ref_int", "names a @PerMap registry, whose rows have no index a program can hold - use a string key", "@Ref into a @PerMap registry is on a string field")
reject_case("rejected/permap_prefix", "a @PerMap registry has no 'as PREFIX'", "a @PerMap registry has no prefix and no constants")
diag_json_case("rejected/permap_maps/permap_maps", 6, "\"line\": 2, \"col\": 18, \"severity\": \"error\", \"message\": \"PropRow has no field colour\"", "--check reads every map: a field its record lacks, at the map file's line and column")
diag_json_case("rejected/permap_maps/permap_maps", 6, "model wants an int, not a string", "--check reads every map: a value of the wrong type")
diag_json_case("rejected/permap_maps/permap_maps", 6, "unknown constant MDL_BENCH", "--check reads every map: a constant nobody declared")
diag_json_case("rejected/permap_maps/permap_maps", 6, "there is no Spots row nowhere in this map", "--check reads every map: a cross-row @Ref names a row of the same map")
diag_json_case("rejected/permap_maps/permap_maps", 6, "the key t1 is also written at", "--check reads every map: a chunked table's key is unique across the map's chunk files, both places named")
diag_json_case("rejected/permap_maps/permap_maps", 6, "this map has no examples/rejected/permap_maps/maps/m1/blades.png", "--check reads every map: a game-wide row's @Asset(..., map) file is in each map's directory, unless optional")
# phase 26: text keys - k"..." / kn"..." of type Key, checked against package.ludic's lang en.po
feat_case("lang/i18n/i18n", "", "pause.resume yes no 13 12 hud.day(3) catch.count[2](trout) items.rope.name items.lamp.name steps.gather_wood.hint", "i18n.ludic (phase 26: k\"...\" and kn\"...\" of type Key, their marker byte, == on keys, a @Text Key field filled with its derived key, @TextKey's prefix)")
i18n_check_case()
feat_case("lang/i18n_nested/i18n_nested", "", "raids.camp.arrive.steps.0.text raids.camp.arrive.steps.1.text 2 raids.camp.said.1 0 raids.quiet.arrive.steps.0.text hud.day(3)", "i18n_nested.ludic (phase 26: derived keys below a row - a nested record's, a list item's, a @Text []Key filled from en.po; null is no text; trf's trailing \"\" not counted)")
schema_case("lang/i18n/i18n", "{\"key\": \"items.rope.name\", \"kind\": \"data\", \"kinds\": [\"data\"], \"sites\": [\"examples/lang/i18n/data/i18n_items.lres:2:1\"], \"in_source\": true, \"english\": \"Rope\"", "--emit-schema: lang.keys - a derived key, its kind, its site and en.po's English")
schema_case("lang/i18n/i18n", "\"unused\": [\"map.close\", \"ui.left_click\", \"ui.middle_click\", \"ui.press_key\", \"ui.right_click\"]", "--emit-schema: lang.unused - en.po's keys the program uses nowhere")
schema_case("lang/i18n/i18n", "\"missing\": [\"hud.day\"], \"fuzzy\": [\"pause.close\"], \"extra\": [\"old.title\"]", "--emit-schema: lang.languages - another .po's missing, fuzzy and extra keys against en.po")
schema_case("lang/i18n/i18n", "{\"english\": \"Close\", \"keys\": [\"map.close\", \"pause.close\"]}", "--emit-schema: lang.split - one English under several keys")
schema_case("actions/modules", "{\"state\": \"Bag\", \"action\": \"PickUp\", \"module\": \"bag\", \"at\": \"examples/actions/mods/bag/index.ludic:4:16\"}", "--emit-schema: code.reducers - a reducer's state, action, module and place")
schema_case("actions/modules", "{\"action\": \"PickUp\", \"module\": null, \"at\": \"examples/actions/modules.ludic:8:21\"}", "--emit-schema: code.dispatch - every dispatch site")
schema_case("modules/ports", "{\"name\": \"Clock\", \"module\": \"clock\", \"at\": \"examples/modules/station/clock/index.ludic:4:13\", \"bound\": true, \"members\": [{\"name\": \"now\", \"type\": \"fn()->int\", \"default\": null, \"required\": true}, {\"name\": \"day\", \"type\": \"fn()->int\", \"default\": \"fn first_day\", \"required\": false}]}", "--emit-schema: code.ports - a port's members, their fn types and defaults")
schema_case("modules/ports", "{\"port\": \"Clock\", \"member\": \"now\", \"fn\": \"game_hours\", \"value\": \"fn game_hours\", \"module\": \"app\", \"at\": \"examples/modules/station/app/index.ludic:3:14\"}", "--emit-schema: code.binds - a bind's member, the function it names and where")
schema_case("modules/ports", "{\"name\": \"clock\", \"package\": null, \"layer\": null, \"uses\": [\"units\"], \"uses_at\": \"examples/modules/station/clock/index.ludic:3\", \"friend\": null, \"files\": [\"examples/modules/station/clock/index.ludic\"]}", "--emit-schema: code.modules - a module's uses and files")
schema_case("modules/friend_of", "{\"name\": \"lab\", \"package\": null, \"layer\": null, \"uses\": null, \"uses_at\": null, \"friend\": {\"of\": [\"bank\"]}", "--emit-schema: code.modules - a friend module and whose names it sees")
schema_case("networking/net_demo", "{\"name\": \"Move\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:24:19\", \"cancellable\": false, \"net\": \"toserver\", \"emits\": [\"examples/networking/net_demo.ludic:32:10\"], \"listeners\": [{\"handler\": \"DoMove\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:25:21\"}]}", "--emit-schema: code.events - a remote event, its emit sites and @On listeners")
schema_case("networking/net_demo", "{\"name\": \"Ship\", \"module\": null, \"at\": \"examples/networking/net_demo.ludic:22:16\", \"owned\": true, \"properties\": [{\"name\": \"Pos\", \"sync\": true}]}", "--emit-schema: code.models - an @Owned model and its @Sync property")
schema_case("networking/net_export", "\"name\": \"Said\", \"module\": null, \"at\": \"examples/networking/net_export.ludic:6:16\", \"cancellable\": false, \"net\": \"toclients\"", "--emit-schema: an attribute before `export` is kept - @ToClients export event is remote")
schema_case("networking/net_export", "\"name\": \"Asked\", \"module\": null, \"at\": \"examples/networking/net_export.ludic:8:26\", \"cancellable\": false, \"net\": \"toserver\"", "--emit-schema: @ToServer export event on one line is remote too")
schema_case("networking/net_export", "\"owned\": true, \"properties\": [{\"name\": \"Spot\", \"sync\": true}]", "--emit-schema: @Owned export model and @Sync export property keep theirs")
schema_case("networking/net_roles", "\"phase\": \"Update\", \"hook\": null, \"target\": null, \"public\": false, \"net\": \"server\"", "--emit-schema: code.handlers - an @Server handler's phase and net role")
schema_case("lang/annotations", "\"queries\": {\"these\": [{\"property\": \"Transform\", \"filter\": \"scale > 0\"}, {\"property\": \"Velocity\", \"filter\": \"dx > 0 or dy > 0\"}], \"on\": \"Actor\"}", "--emit-schema: code.handlers - @Queries' terms, filters and model")
schema_case("lang/annotations", "\"phase\": null, \"hook\": \"OnSpawn\", \"target\": \"Actor\"", "--emit-schema: code.handlers - a lifecycle hook and the model it names")
schema_case("lang/scenes", "{\"name\": \"Title\", \"module\": null, \"at\": \"examples/lang/scenes.ludic:24:9\", \"start\": true, \"public\": false, \"shows\": null, \"lasts\": null, \"then\": null, \"loads\": null, \"enter\": true, \"exit\": true, \"layers\": [\"Main\"]}", "--emit-schema: code.scenes - the start scene, its blocks and layers")
schema_case("lang/scenes", "\"scene\": \"Play\", \"layer\": \"Hud\"", "--emit-schema: code.handlers - a scene's handler, its scene and layer")
schema_case("state/write_reach", "{\"fn\": \"log_note\", \"module\": null, \"at\": \"examples/state/write_reach.ludic:21:37\", \"slot_kind\": \"registry\", \"slot\": \"Kinds.note\", \"entry\": \"box\"}", "--emit-schema: code.fn_refs - a fn in a registry row, the field and row it fills")
schema_case("modules/ports", "\"slot_kind\": \"port_default\", \"slot\": \"Clock.day\"", "--emit-schema: code.fn_refs - a port member's default")
diag_json_case("rejected/builtin_call_name", 2, "words is a built-in of the compiler, and a call to it would never reach this function; choose another name", "a function named like a built-in a call always takes (words, keep) is refused at its declaration")
feat_case("functions/builtin_yield", "", "ab 4", "builtin_yield.ludic (a built-in a declared function may take - buffer - yields to it in the checker as in codegen)")
diag_json_case("rejected/i18n_keys/key_missing", 2, "the key pause.gone is not in examples/rejected/i18n_keys/lang/en.po", "a key literal en.po does not have is refused, at its line and column")
diag_json_case("rejected/i18n_keys/key_missing", 2, "is a plural key, and examples/rejected/i18n_keys/lang/en.po:7 gives pause.resume no msgid_plural", "kn\"...\" is a plural key: en.po's entry has a msgid_plural")
diag_json_case("rejected/i18n_keys/key_missing", 2, "trf: hud.day is given 2 value(s), and its English in examples/rejected/i18n_keys/lang/en.po has 1 hole(s)", "trf given more values than the key's English has holes: a warning")
diag_json_case("rejected/i18n_keys/key_missing", 2, "shop.close has no #. description, and its English", "one English split into several keys wants a #. description on each: a warning at en.po's line")
diag_json_case("rejected/i18n_keys/key_mixup", 3, "argument 1 of show wants a string and this is a Key", "a Key is not a string")
diag_json_case("rejected/i18n_keys/key_mixup", 3, "argument 1 of name_of wants a Key and this is a string", "a string is not a Key")
diag_json_case("rejected/i18n_keys/key_mixup", 3, "+ on a Key", "a Key joins no text")
diag_json_case("rejected/i18n_keys/key_hole", 2, "a Key in a template's {hole}", "a Key in a template literal's hole is refused: it would draw its marked name, untranslated")
diag_json_case("rejected/i18n_keys/key_rules", 3, "trn counts, so its key is a plural one", "trn wants a plural key, kn\"...\"")
diag_json_case("rejected/i18n_keys/key_rules", 3, "a plural key needs its count", "tr / trf refuse a plural key")
diag_json_case("rejected/i18n_keys/data_missing", 2, "things.axe.name (the derived key of a @Text field the row gives no value): the key things.axe.name is not in", "a @Text Key field a row leaves out: its derived key must be in en.po")
diag_json_case("rejected/i18n_keys/data_missing", 2, "English left: things.axe.blurb is", "a @Text string field still holding English: an error (26.9)")
diag_json_case("rejected/i18n_keys/english_left", 2, "English left: a template's words (\\\"Paused\\\")", "a template's words outside translate=\"no\": an error (26.9)")
diag_json_case("rejected/i18n_keys/t_missing", 1, "t('menu.gone'): the key menu.gone is not in", "a template's t('key') is checked against en.po")
diag_json_case("rejected/i18n_keys/t_missing", 1, "t(): hud.day is given 0 value(s)", "a template's t('key') given fewer values than the English has holes: a warning")
feat_case("lang/defaults", "", "a 300.0 8.0 / b 120.0 8.0 / c! 300.0 8.0 / d 300.0 2.0 / x / y", "defaults.ludic (L11: default parameters, positional then named arguments, in a generic too)")
reject_case("rejected/default_missing", "this call to f leaves out a, which has no default", "a call leaves out only what has a default")
reject_case("rejected/default_unknown", "f has no parameter c", "a named argument names a parameter the function has")
reject_case("rejected/positional_after_named", "a positional argument after a named one", "positional arguments come first")
controller_case("library/ui_templates", "", "ui: ui/peek.xml:1: <kit:Price>: that component is not exported - export=\"true\" on it lets other files use it purse 70, bought 1; the lantern is enabled: 0", "ui_templates.ludic (L11: a template reads a view, a press sends its event, component state and emit)")
controller_case("library/ui_flex", "", "grow 97:282; between at 197; wrap lines at 51, 67; red 16711680; stretched 40; own size 12", "ui_flex.ludic (L11: flex layout - grow, justify, wrap, stretch - and stylesheets with imports and specificity)")
controller_case("library/ui_html", "", "id beats class: 40; card 200 wide at 3,53 pad 4 8 12 16 border 2 inside 65280, outside -1; warn bg 16776960; items 3 of 30: 30; colours 16711680 255 -1; hr 1; children 7 clicks 1; the button now disabled 1 at opacity 0.5; text-align 1", "ui_html.ludic (L11: HTML elements, ids, classes, attributes, style=, CSS selectors and properties, the box model)")
controller_case("library/ui_react", "", "Mixer|loud|dark|drums 1|bass 0|checkbox true| volume 7; dial 40x40; mounted 1, unmounted 1", "ui_react.ludic (L11: keyed lists, let, context, named slots, mount and unmount, native elements and form controls)")
controller_case("library/ui_css", "", "badge at 255,175 bg 16746496; nudged 17,13; big 32; corner 0,0 40x32 media keeps narrow; li gaps 6 0; li colours 255 -1 255; after 16746496; fallback 65280 checked opacity 0.25; list scrolls 1 50 of 80; para lines 5", "ui_css.ludic (L11: var(), positioning, em/rem/vw, @media, wrapping, sibling combinators, :nth-child formulas, :checked, overflow)")
controller_case("library/ui_reload", "", "ui: templates reloaded note 1 | reloaded 0 then 1 | note again 1", "ui_reload.ludic (L11: a template changed on disk is read again, and its state kept)")
controller_case("library/ui_component", "", "counters|a: 1 (2)|+|reset|b: 10 (20) big|+|reset|outside| b margin 9; b text 16711680; outside -1; button 30 b: 0 (0)", "ui_component.ludic (L11: components as NAME.ludic + .xml + .lss - props, state, fields, events, scoped styles, @import, class passed down)")
controller_case("library/ui_override", "", "counters|a: 1 (2)|+|reset|b: 0 (0)|+|reset|outside| counters|[a=1]|+|[b=0]|+|outside| 1 counters|a: 1 (2)|+|reset|b: 0 (0)|+|reset|outside| 0", "ui_override.ludic (a template given from outside: shown with the state kept, and the file back when let go)")
controller_case("library/ui_override_up", "", "counters|[a=0]|+|[b=0]|+|outside| 1 1", "ui_override_up.ludic (a component registered under a path with .. in it - a game's lab build - still matched by the studio's plain path)")
controller_case("library/ui_mounted", "", "App|Counter| 1 1", "ui_mounted.ludic (the components the last frame showed, in tree order and each once, and one's model)")
reject_case("rejected/component_no_template", "there is no Lonely.xml beside it", "a component needs its template beside it")
controller_case("library/ui_remount", "", "c: 2 (4) | c: 0 (0) | heap 0", "ui_remount.ludic (a component unmounted and mounted again starts as new, and two thousand comings and goings hold the heap flat)")
controller_case("library/ui_controls", "", "1 false 7 2 Adalove 70 1 false 8 2 Adalove 70; scrolled 40", "ui_controls.ludic (L11: focus and keys, the built-in controls, a range dragged, a box scrolled)")
controller_case("library/ui_anim", "", "0 100 100 | 49 50 100 | 100 100 61", "ui_anim.ludic (L11: @keyframes and animation, looping and alternating, and a transition)")
controller_case("library/ui_popover", "", "drop false | drop true | drop false | drop false | fill 60 of 200", "ui_popover.ludic (L11: a popover keeps the keys and closes on a press outside or Esc; progress fills)")
controller_case("library/ui_popover_mouse", "", "over the cell 1 | opened 1 dropped 0 closed 0 true | opened 1 dropped 1 closed 0 false | opened 2 dropped 1 closed 1 false | opened 3 dropped 1 closed 2 false", "ui_popover_mouse.ludic (L11: a popover takes the mouse - its own button wins over the cell under it, and a press outside closes it and presses nothing)")
controller_case("library/ui_scrollbar", "", "grab 0 moved 120 far 300 back 60 kept 60 | jump 270 | wheel 230 | pressed 1", "ui_scrollbar.ludic (L11: a scrollbar's thumb held where it was taken and dragged, a press on the track jumps there, the wheel, nothing under the bar pressed)")
controller_case("library/ui_ease", "", "60: 1480 680 slid 1 landed 1 left 0; 120: 1480 680 slid 1 landed 1 left 0; 180: 1480 680 slid 1 landed 1 left 0; 240: 1480 680 slid 1 landed 1 left 0;", "ui_ease.ludic (L11: a transition and an animation at 60, 120, 180 and 240 Hz are the same half way and land exactly on their targets)")
controller_case("library/ui_gamepad", "", "last 6 vol 0 q 0 open false | last 0 vol 0 q 0 open false | last 0 vol 2 q 0 open false | last 0 vol 2 q 2 open false | last 0 vol 2 q 2 open true | last 0 vol 2 q 2 open false", "ui_gamepad.ludic (L11: the pad and held keys - a held direction repeats on the clock at any rate, A presses, left and right step, B closes a popover)")
controller_case("library/ui_pointer", "", "pointerdown 100,80 0 0; pointerup 300,300 0 0; wheel 50,50 0 1; dragged 200 | zoomed 1 pad 7 10 1 2 hold true 0 0 active 16711680 | zoomed 1 pad 7 10 1 2 hold false 1 0 | zoomed 1 pad 7 10 1 2 hold true 1 0 | zoomed 1 pad 7 10 1 2 hold false 2 0 3817286", "ui_pointer.ludic (L11: pointer events for natives and markup, capture until release, a button over a native keeps its press; on-down, on-up and :active)")
controller_case("library/ui_popover_align", "", "centred, flipped in the panel 90,40 | end below 110,70 | above b in the list 0 inside 1", "ui_popover_align.ludic (L11: anchored popovers aligned along their side, flipped and kept within a named container or their scroll box)")
controller_case("library/ui_text", "", "Photograph the Be...@100 y0 | Short@160 y20 | Medium words@133 y40 | lines 5 160 one two@160 y66 three@160 y98 | tall 30 | em 20 24 | tie #00ff00", "ui_text.ludic (L11: text-fit shrinks then cuts, line-height, em against the final font size, a sheet over its imports on a tie)")
controller_case("library/ui_calc", "", "min 200 max 82 clamp 50 21 | p 150,60 q 255,80 r 30", "ui_calc.ludic (L11: min(), max() and clamp(), and insets as percentages and calc() of the containing block)")
controller_case("library/ui_tkey", "", "#pause.resume #hud.day|12 #hud.at|lake|3 same", "ui_tkey.ludic (t() in a template: the key marked, its holes after it, for the translator)")
controller_case("library/ui_nine", "", "cap 0 12 18 30/ 0 8 8 16 | wide 0 12 88 100/ 0 12 28 40 | odd 10 21 21 31/ 4 8 8 13", "ui_nine.ludic (L11: a nine-slice's corners clamped to half the box each way, on whole pixels)")
controller_case("library/ui_scroll_hold", "", "120 50 0 top 120 | 80 50 0 top 80 | 80 50 0 top 80 | 80 50 70 top 80 | 80 50 30 top 80", "ui_scroll_hold.ludic (L11: scroll-top holds a box at an offset, on-scroll follows the wheel, ui_scroll_set moves one once)")
controller_case("library/ui_fill", "", "0,0 2x20 #030303 .. 98,0 2x20 #fcfcfc | 0,20 50x2 #f2000d .. 0,38 50x2 #0d00f2 .. 0,58 50x2 #0000ff of 70 | 0,85 100x50 | -50,160 200x100 clipped 1 | ratio 90 knob 60", "ui_fill.ludic (L11: linear-gradient backgrounds, object-fit contain and cover, aspect-ratio for boxes and natives)")
controller_case("library/ui_translate", "", "Merhaba Hello Hello Merhaba | c/Kaydet ve cik | Satir bir/Satir iki | Line one/Line two", "ui_translate.ludic (L11: translate=no and yes, a title of several lines translated whole or line by line)")
controller_case("library/ui_keycap", "", "listening 16711680 | 257 Right click -2 | 258 Middle click -2 | 256 Left click -2 | pressed 0", "ui_keycap.ludic (L11: a key field takes the left, right and middle mouse buttons, and is :capturing while it listens)")
controller_case("library/ui_focus_tip", "", "b 14,38 | Drop it 27,45 | Give it away 27,77 | b 14,38", "ui_focus_tip.ludic (L11: a title shows for the keyboard's focus after half a second, and goes back to the pointer when it moves)")
controller_case("library/ui_theme", "", "accent #ff0000 gap 7", "ui_theme.ludic (L11: a component with no stylesheet reads the theme's :root variables from the screen around it)")
controller_case("library/ui_box", "", "scale 1.5 | panel 35,75 150x90 | missing 1", "ui_box.ludic (L11: ui_scale and ui_box, the interface's scale and an element's laid-out box, read from outside)")
controller_case("library/ui_emit_click", "", "got 2 | pressed: got 1", "ui_emit_click.ludic (L11: a component's `emit click` reaches its user's on-click, by the mouse and by a press)")
controller_case("library/ui_select_arrows", "", "prev 0 next next 2", "ui_select_arrows.ludic (L11: a select's < steps back and its > forward)")
controller_case("library/ui_pointer_through", "", "pressed 1, hovered #4a5058; > hovered 1", "ui_pointer_through.ludic (L11: the pointer looks through a root with no size to a positioned panel's button; a select's > answers :hover)")
controller_case("library/ui_first_show", "", "bad 0 shown 3", "ui_first_show.ludic (L11: a component first shown once the fence is judging makes its props and model declared, once per instance; shown again, it is reused)")
controller_case("library/ui_select_disabled", "", "clamped 2 next-on 0 next 0 prev 2", "ui_select_disabled.ludic (L11: <option disabled>: a value on one is set to the nearest enabled, the arrow toward them drawn disabled, the arrows step past them)")
controller_case("library/ui_submit", "", "[hi] chat '' | [hi][yo][Ada] chat '' name 'Ada'", "ui_submit.ludic (L11: on-submit on a text field by Enter or A, clear-on-submit, the focus kept)")
controller_case("library/ui_zoom", "", "plain 100 pad 10 text 16 h 20 | half 50 pad 5 text 8 h 10 | half of twice 100 | fit 50 em 16", "ui_zoom.ludic (L11: zoom scales a subtree's lengths, text and layout, multiplies, and takes min() and a length over a length)")
controller_case("library/ui_hold", "", "31 frames 500 ms t 500 | 121 frames 500 ms t 500 | pad 16 frames 250 ms t 250 | dududu", "ui_hold.ludic (L11: on-hold every frame a button is held, by the pointer or A, with dt and t on the clock at any rate)")
controller_case("library/ui_inputs", "", "count 12 12 32 0.5 0 | 20 32 0.5 0 | 19 32 0.5 0 | How many to carry | Space bar listening 1 19 9 0.5 0 | 19 9 0.5 0 | 19 0 0.5 0 | 60% 0.50 s | 19 0 0.6 9 | 7!", "ui_inputs.ludic (L11: number fields, key capture with Esc and Backspace, notes, range formats, an event named set, a string prop given a number)")
controller_case("library/ui_boxes", "", "list 120 of 200 | text at 32 152 wide, 3 lines, row 60 | zero 0x0 | half 140x36 pad 4 2 | order abc at 16 | fill 98x8 of 200x12", "ui_boxes.ludic (L11: flex-shrink to the leftover room, text wrapping beside its siblings, zero sizes, calc(), order, the range track as a row)")
controller_case("library/ui_look", "", "root div.in.i.m pad 5 margin 9 color #ff0000 text Hi 2,53 #102030 | text Hi 0,50 #eeeeee | opacity 0.25 | nine 40 #ffffff nine 40 #ff0000 of 2 image photo.png #ffffff | image icon:tent #00ff00 | text first line 29,205 #eeeeee | text second line 29,223 #eeeeee by id at 84,50 | flipped at 246,268 100x32", "ui_look.ludic (L11: nested components styled by specificity, text-shadow, ui_opacity, border-image tints, pictures, tooltip lines, anchored popovers)")
reject_case("rejected/view_untyped", "view Sums: say what type total is", "a view field that is not a bare global says its type")
reject_case("rejected/view_without_ui", "a view is shown by ludic.ui", "a view needs ludic.ui to show it")
# 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")
# #43: engine-owned systems auto-advance the SpriteAnim / Motion components a
# game merely declares and carries — no handler wired. Entry-driven so it can
# assert the auto-advanced fields at known tick counts.
net_case("library/anim_ecs", "1 5 2 10 0 2 10 1 1")
# #47: the engine light pass consumes Light2D / Occluder / Ambient components at
# the end of the Render phase — ambient tint, additive glow, hard shadow — with
# no Light.* calls wired. Sampled back off the framebuffer with Screen.pixel.
net_case("library/light_ecs", "32 1 32 1")
# #7: action maps (read named actions, not keys), runtime rebinding, and
# deterministic input record/replay — fed one key per poll from stdin.
feat_case("library/input_actions", " xwa", "1 0 1 1 0 1 0", "input_actions.ludic (#7 action maps + rebinding + deterministic replay)")
feat_case("library/input_device", "", "1 1 0 1 0 1 71 -71 5 1 3 1 2 1 0 0 1", "input_device.ludic (#50 multi-key held state + analog axis/vector + mouse + gamepad/touch + full-state replay)")
feat_case("library/input_mouse_rebase", "", "0 0 5 -2 0 0 4 3 6 0 0 0", "input_mouse_rebase.ludic (mouse_dx/dy report no motion on the first frame or across a cursor-mode change)")
feat_case("library/input_manager", "", "1 1 1 0 1 0 0 1", "input_manager.ludic (#83 Input Manager: default bindings + device-agnostic actions across keyboard/pad + on-press/on-release edges)")
feat_case("library/input_auto", "llq", "2", "input_auto.ludic (#83 the frame loop auto-commits the device layer: Input.active fires with no manual Input.poll)")
feat_case("library/input_edge", "xkkxq", "0 11 1 100 0", "input_edge.ludic (#87 Input.key_pressed/key_released edges fire on the transition frame; no double-commit under the frame loop)")
feat_case("library/input_movement", "", "1 0 -1 -1 0", "input_movement.ludic (#79 Input.axis_i turns WASD into a -1/0/1 movement intent, no bool->int glue)")
feat_case("library/input_arrows", "", "128 129 130 131 1 0 -1 1 0", "input_arrows.ludic (Key.Up/Down/Left/Right are the held set's 128-131: key_down and move_i take the arrows)")
feat_case("library/input_key_label", "", "W A ; 1 Space Esc Shift Left Right 0 1", "input_key_label.ludic (Input.key_label names a physical key: the US character headless, words for the named keys and arrows)")
feat_case("library/input_typeless_keys", "", "132 143 144 149 150 152 161 F1 F12 Home Delete CapsLock Num 0 Num / 1 0 1", "input_typeless_keys.ludic (the F-row, the six-pack, Caps Lock and the numpad are codes 132-166: bindable, in the held set, and named without asking the layout)")
feat_case("library/audio", "", "0 0 0 0 1", "audio.ludic (#22 Audio.* load/play/music/volume/pitch/stop/is_playing — headless no-op)")
# #6 Http.* — the client links Foundation (macOS-only), so build it through the
# canonical `ludicc -o` path (which wires the framework) and gate on Darwin. The
# run exercises the pure-Ludic response parser; no network is touched.
if is_darwin() {
if shq(`LUDIC_HOME=. bin/ludicc examples/library/http.ludic -o {tmp_dir()}/http_demo > {tmp_dir()}/http.out 2>&1`) {
let got = capture_line(`{tmp_dir()}/http_demo < /dev/null`)
if (got == "200 1 5 hello 42 text/plain 404 0") { ok("http.ludic (#6 Http.parse/status/ok/text/header — pure response parser)") }
else { bad2("http.ludic", `got [{got}]`) }
} else { bad2("http.ludic build", capture_line(`tail -1 {tmp_dir()}/http.out`)) }
# and through `ludic build`, which is how a project builds: compile_app used to link
# gl.ll when a program named @lgl_* but never http.ll for @hs_*, so this failed to link
if shq(`LUDIC_HOME=. bin/ludic build examples/library/http.ludic --headless -o {tmp_dir()}/http_lb > {tmp_dir()}/http_lb.out 2>&1`) {
let got2 = capture_line(`{tmp_dir()}/http_lb < /dev/null`)
if (got2 == "200 1 5 hello 42 text/plain 404 0") { ok("ludic build links http.ll for a program that uses Http.*") }
else { bad2("http.ludic via ludic build", `got [{got2}]`) }
} else { bad2("ludic build of an Http.* program", capture_line(`tail -1 {tmp_dir()}/http_lb.out`)) }
# Http.save_to: a body streamed to a file, counted as it goes - through a file:// URL,
# so no network is touched; the copy has to be byte-identical
if shq(`LUDIC_HOME=. bin/ludicc examples/library/http_save.ludic -o {tmp_dir()}/http_save > {tmp_dir()}/http_save.out 2>&1`) {
let root = capture_line("pwd")
shell(`rm -f {tmp_dir()}/http_save.copy`)
let got3 = capture_line(`{tmp_dir()}/http_save {root}/selfhost/ludicc.seed.ll {tmp_dir()}/http_save.copy < /dev/null`)
let same = shq(`cmp -s {root}/selfhost/ludicc.seed.ll {tmp_dir()}/http_save.copy`)
if (got3 == "0 -1 200 1 1 1 1") and same { ok("http_save.ludic (Http.save_to/received/expected - a streamed download, byte for byte)") }
else { bad2("http_save.ludic", `got [{got3}] identical {same}`) }
} else { bad2("http_save.ludic build", capture_line(`tail -1 {tmp_dir()}/http_save.out`)) }
}
# Udp.*: two sockets on the loopback, a datagram each way, through ludicc and ludic build
if is_darwin() {
let udp_want = "2 2 104 105 1 2 127.0.0.1 1 0 1"
if shq(`LUDIC_HOME=. bin/ludicc examples/library/udp.ludic -o {tmp_dir()}/udp_demo > {tmp_dir()}/udp.out 2>&1`) {
let got = capture_line(`{tmp_dir()}/udp_demo < /dev/null`)
if (got == udp_want) { ok("udp.ludic (Udp.open/send/recv/from/ip - datagrams over the loopback)") }
else { bad2("udp.ludic", `got [{got}]`) }
} else { bad2("udp.ludic build", capture_line(`tail -1 {tmp_dir()}/udp.out`)) }
if shq(`LUDIC_HOME=. bin/ludic build examples/library/udp.ludic --headless -o {tmp_dir()}/udp_lb > {tmp_dir()}/udp_lb.out 2>&1`) {
let got2 = capture_line(`{tmp_dir()}/udp_lb < /dev/null`)
if (got2 == udp_want) { ok("ludic build links udp.ll for a program that uses Udp.*") }
else { bad2("udp.ludic via ludic build", `got [{got2}]`) }
} else { bad2("ludic build of a Udp.* program", capture_line(`tail -1 {tmp_dir()}/udp_lb.out`)) }
}
# Process.*: a child polled to its exit code without blocking, argv passed untouched, a
# kill, a missing program - through ludicc and ludic build (the children are /bin/sh)
if is_darwin() {
let proc_want = "3 1 7 137 -1 255"
if shq(`LUDIC_HOME=. bin/ludicc examples/library/process.ludic -o {tmp_dir()}/proc_demo > {tmp_dir()}/proc.out 2>&1`) {
let got = capture_line(`{tmp_dir()}/proc_demo < /dev/null`)
if (got == proc_want) { ok("process.ludic (Process.spawn/poll/kill/free - child processes, polled)") }
else { bad2("process.ludic", `got [{got}]`) }
} else { bad2("process.ludic build", capture_line(`tail -1 {tmp_dir()}/proc.out`)) }
if shq(`LUDIC_HOME=. bin/ludic build examples/library/process.ludic --headless -o {tmp_dir()}/proc_lb > {tmp_dir()}/proc_lb.out 2>&1`) {
let got2 = capture_line(`{tmp_dir()}/proc_lb < /dev/null`)
if (got2 == proc_want) { ok("ludic build links process.ll for a program that uses Process.*") }
else { bad2("process.ludic via ludic build", `got [{got2}]`) }
} else { bad2("ludic build of a Process.* program", capture_line(`tail -1 {tmp_dir()}/proc_lb.out`)) }
}
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")
smoke("library/cursor_capture") # #89 Input.cursor_mode compiles (no-op headless; windowed links cocoa.ll)
smoke("rendering/gl_triangle") # Gl.* (OpenGL 4.1 core) compiles headless; the run needs a GPU context
syntax_cases()
print("== the compiler and the CLI (ludicc / ludic) ==")
# ludicc comes out of the IR seed with clang alone; the CLI is then compiled
# by it, from Ludic.
if shq(`bin/ludic-dev build-cli > {tmp_dir()}/cli.out 2>&1`) and is_exec("bin/ludicc") {
ok("ludic-dev build-cli builds bin/ludicc from the seed (clang-only)")
} else { bad2("ludic-dev build-cli", capture_line(`tail -1 {tmp_dir()}/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_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`file {tmp_dir()}/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_dir()}/cli.out`)) }
# #51 — a windowed build must load GameController so the native gamepad
# polling (win_pad, reached on the is_windowed branch) can see controllers.
if shq(`otool -L {tmp_dir()}/cli_snake 2>/dev/null | grep -q GameController`) {
ok("windowed link loads GameController (#51 gamepad binding)")
} else { bad("windowed binary is missing the GameController load command") }
} else {
if shq(`LUDIC_HOME=. bin/ludicc --headless examples/games/snake.ludic -o {tmp_dir()}/cli_snake > {tmp_dir()}/cli.out 2>&1`) and shq(`test -x {tmp_dir()}/cli_snake`) {
ok("ludicc app.ludic -o bin -> native executable (headless)")
} else { bad2("ludicc -o", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
}
# --emit-llvm stops at the IR.
if shq(`bin/ludicc examples/games/snake.ludic --emit-llvm -o {tmp_dir()}/cli_snake.ll > {tmp_dir()}/cli.out 2>&1`) and shq(`head -1 {tmp_dir()}/cli_snake.ll | grep -q 'LLVM IR'`) {
ok("ludicc --emit-llvm -> LLVM IR")
} else { bad2("ludicc --emit-llvm", capture_line(`tail -1 {tmp_dir()}/cli.out`)) }
# `ludic <file>` compiles, runs, and forwards the program's exit code.
write_file(`{tmp_dir()}/cli_exit.ludic`, "entry { exit(42) }\n")
let rc = sh(`bin/ludic {tmp_dir()}/cli_exit.ludic --headless > {tmp_dir()}/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)}`) }
# The CLI has to work as an install does: from another directory, with no
# ./bin, finding its compiler, the engine runtime and the bundled packages
# from its own location. This is the shape `curl … | sh` produces.
install_layout_case()
scaffold_names_case()
package_scripts_case()
test_dir_case()
packages_test_case()
deps_case()
check_build_case()
lab_plate_case()
pack_roundtrip_case()
packignore_case()
os_dirs_case()
app_icon_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is
# published with the docs site — so it is checked here rather than discovered
# broken by a first-time user. `sh -n` parses it without running anything.
if not file_exists("install.sh") { bad("install.sh is missing") }
else if shq("sh -n install.sh 2>/dev/null") and shq("sh install.sh --help >/dev/null 2>&1") {
ok("install.sh parses under POSIX sh and answers --help")
} else { bad("install.sh does not parse under POSIX sh") }
# the package manager (issue #63): fetch + MVS resolve + content-addressed
# store + ludic_modules/ links + build, run as its own hermetic suite (it
# stands up throwaway git repos, so like selfhost-test it runs as a sub-process
# and this stage checks its footer).
print("== package manager (ludic-dev test-pkg) ==")
shell(`bin/ludic-dev test-pkg > {tmp_dir()}/pkg.out 2>&1`)
if shq(`grep -q '0 failed' {tmp_dir()}/pkg.out`) {
let pc = capture_line(`grep -c PASS {tmp_dir()}/pkg.out`)
ok(`package-manager suite: {pc} checks passed (see: ludic-dev test-pkg)`)
} else { bad2("ludic-dev test-pkg", capture_line(`grep -i fail {tmp_dir()}/pkg.out | head -1`)) }
# 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 --check mode regenerates the tracked
# emit_color.ludic + palette.json into scratch files and compares).
print("== docs site (Ludic generator, no Python) ==")
if shq(`bin/ludic-dev docs-gen --out {tmp_dir()}/docs > {tmp_dir()}/docs.out 2>&1`) {
ok("ludic-dev docs-gen -> the pages-branch payload")
} else { bad2("ludic-dev docs-gen", capture_line(`tail -1 {tmp_dir()}/docs.out`)) }
if shq(`bin/ludic-dev docs-check {tmp_dir()}/docs > {tmp_dir()}/docs_chk.out 2>&1`) {
ok("ludic-dev docs-check passes on the generated site")
} else { bad2("ludic-dev docs-check", capture_line(`tail -1 {tmp_dir()}/docs_chk.out`)) }
if shq(`bin/ludic-dev docs-palette --check > {tmp_dir()}/pal.out 2>&1`) {
ok("ludic-dev docs-palette regenerates emit_color.ludic + palette.json byte-identically")
} else { bad2("ludic-dev docs-palette --check", capture_line(`tail -1 {tmp_dir()}/pal.out`)) }
# the OpenGL binding generator is the same shape: gl_api.ludic + gl_thunks.ll are
# tracked, and --check regenerates them from the platform gl3.h and compares. The
# header is macOS-only, so this guard cannot run off Darwin.
if is_darwin() {
if shq(`bin/ludic-dev glgen --check > {tmp_dir()}/glgen.out 2>&1`) {
ok("ludic-dev glgen regenerates gl_api.ludic + gl_thunks.ll byte-identically")
} else { bad2("ludic-dev glgen --check", capture_line(`tail -1 {tmp_dir()}/glgen.out`)) }
} else { skip("ludic-dev glgen --check (needs the macOS OpenGL headers)") }
# and the Vulkan one: vk_api.ludic + vk_thunks.ll regenerated from the registry's vk.xml,
# which comes with the Vulkan SDK - so this guard runs wherever the SDK is installed
if shq("ls $VULKAN_SDK/share/vulkan/registry/vk.xml $HOME/VulkanSDK/*/macOS/share/vulkan/registry/vk.xml 2>/dev/null | grep -q vk.xml") {
if shq(`bin/ludic-dev vkgen --check > {tmp_dir()}/vkgen.out 2>&1`) {
ok("ludic-dev vkgen regenerates vk_api.ludic + vk_thunks.ll byte-identically")
} else { bad2("ludic-dev vkgen --check", capture_line(`tail -1 {tmp_dir()}/vkgen.out`)) }
} else { skip("ludic-dev vkgen --check (needs the Vulkan SDK's vk.xml)") }
# render3d's shader variants as SPIR-V: tracked, so a change to a shader or to variants.list
# that nobody rebuilt fails here, wherever the Vulkan SDK (glslangValidator) is installed
if shq("ls $VULKAN_SDK/bin/glslangValidator $HOME/VulkanSDK/*/macOS/bin/glslangValidator 2>/dev/null | grep -q glslangValidator") {
if shq(`bin/ludic-dev shaders --check > {tmp_dir()}/shaders.out 2>&1`) {
ok("ludic-dev shaders rebuilds render3d's SPIR-V byte-identically")
} else { bad2("ludic-dev shaders --check", capture_line(`tail -1 {tmp_dir()}/shaders.out`)) }
} else { skip("ludic-dev shaders --check (needs the Vulkan SDK's glslangValidator)") }
return report()
}