1545 lines
127 KiB
Text
1545 lines
127 KiB
Text
# 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 }
|
||
if not s_contains(capture(`cat {js}`), "@Unit(\"°\"): the unit is spelled \"deg\"") { bad2(lbl, capture_line(`head -3 {js}`)); return }
|
||
ok(lbl)
|
||
}
|
||
# --check --diagnostics=json: refused, with exactly `n` diagnostics on stdout, one of them 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}]`) }
|
||
}
|
||
|
||
# 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\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; --widest and --reach
|
||
# list the functions by what they take and by what they reach
|
||
function deps_reach_case() -> void {
|
||
let lbl = "ludic deps: the reach through fn values, --widest N and --reach N"
|
||
let got = capture(`bin/ludic deps examples/state/reach.ludic 2>&1`)
|
||
if not s_contains(got, "widest_reach: 3") or not s_contains(got, "the widest reach: one (examples/state/reach.ludic:13), 3 states (2 through calls and fn values it does not take)") { 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)") { 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)
|
||
}
|
||
|
||
# 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", 2, "@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", 10, "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")
|
||
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", "", "true", "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", 4, "\"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", 4, "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", 4, "unknown constant MDL_BENCH", "--check reads every map: a constant nobody declared")
|
||
diag_json_case("rejected/permap_maps/permap_maps", 4, "there is no Spots row nowhere in this map", "--check reads every map: a cross-row @Ref names a row of the same map")
|
||
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)")
|
||
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_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
|
||
|
||
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()
|
||
}
|