feat(testing): built-in test blocks + expect assertions, auto-run with pass/fail (#12)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 17s
ci / build-and-test (push) Successful in 1m13s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 19s

A `test "name" { ... }` top-level block is discovered automatically and run by a
synthetic runner @main — no `entry` to write, nothing to register. Inside a test,
expect(cond) / expect_eq(a, b) / expect_near(a, b, tol) assert; on failure they
print `file:line: <what> failed (got G, want W)` and set a per-test fail flag but
keep going, so one run reports every failure. The runner prints `ok   - name` /
`FAIL - name` per test, a `== N passed, M failed ==` summary, and exits non-zero
if any test failed — so `ludic spec_test.ludic` drops straight into bin/x and CI.
expect_near carries the tolerance fixed-point / accumulated-integer game math need.

Frontend: new `test` keyword (parse_test -> N_TEST, collected in g_tests) and the
call node now carries its source line for the file:line messages. Backend:
emit_test_runner synthesises @fn__test_i bodies + the runner @main; the expect*
builtins lower to a branch-print-flag tail (emit_expect_fail). g_src_name (set in
main from the input path) supplies the filename. The compiler's own source has no
`test` blocks, so its self-compiled IR is unchanged and the C-free fixpoint holds.

- `test` wired into the vocabulary (ludic_syntax.h, JetBrains lexer, TextMate
  grammar) and documented (docs/language/testing/)
- examples/library/testing.ludic: a passing spec, guarded by a new spec_case in
  the regression suite (build, run, require exit 0 + the expected summary)

Coverage instrumentation (the biggest lift in #12) is left as a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 14:27:45 +03:00
parent a71279a7b9
commit ea2c6ab246
16 changed files with 15911 additions and 14867 deletions

View file

@ -4,6 +4,25 @@
# runtime builtin plus the parameter labels callers may use as named arguments;
# after reordering we rewrite the callee to that bare name and fall back into the
# ordinary builtin path (which resolves it to its rt_ function).
# Emit the failure tail of an `expect*` assertion: if `cond1` (an i1) is false,
# set the per-test fail flag and print the message, then continue. With got/want
# codes it prints `<msg> (got G, want W)`; otherwise just the message. Leaves the
# block live (falls through), so a test keeps running and reports every failure.
function emit_expect_fail(cond1: pointer, msgsym: pointer, got: pointer, want: pointer) -> void {
let lok = lbl("exok")
let lbad = lbl("exbad")
emit(` br i1 {cond1}, label %{lok}, label %{lbad}\n`)
emit(`{lbad}:\n`)
emit(" store i32 1, ptr @L_test_fail\n")
if (got == "") {
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_line, ptr {msgsym})\n`)
} else {
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_expect, ptr {msgsym}, i32 {got}, i32 {want})\n`)
}
emit(` br label %{lok}\n`)
emit(`{lok}:\n`)
}
function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
# Math.* is computed inline (deterministic fixed-point), not routed through a
# bare rt_ name — so `floor`/`round`/`lerp` never leak into the bare namespace.
@ -396,6 +415,39 @@ function emit_call(e: Node) -> Val {
}
if (name == "fixed") { let a = emit_expr(e.kids[0]); return val(emit_bind(`shl i32 {a.code}, 16`), "fixed") }
if (name == "floor") { let a = emit_expr(e.kids[0]); return val(emit_bind(`ashr i32 {a.code}, 16`), "int") }
# --- the testing framework's assertions (see emit_test_runner) --------------
# expect(cond) / expect_eq(a, b) / expect_near(a, b, tol): on failure they set
# the per-test fail flag (@L_test_fail) and print `file:line: <what> failed`,
# then fall through so a test keeps running and reports every failure. Meant to
# be used inside a `test "name" { ... }` block.
if (name == "expect") {
g_uses_expect = true
let a = emit_expr(e.kids[0])
let c = emit_bind(`icmp ne i32 {a.code}, 0`)
let msg = emit_str_const(`{g_src_name}:{itoa(e.line)}: expect failed`)
emit_expect_fail(c, msg, "", "")
return val("0", "void")
}
if (name == "expect_eq") {
g_uses_expect = true
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1])
let c = emit_bind(`icmp eq i32 {a.code}, {b.code}`)
let msg = emit_str_const(`{g_src_name}:{itoa(e.line)}: expect_eq failed`)
emit_expect_fail(c, msg, a.code, b.code)
return val("0", "void")
}
if (name == "expect_near") {
g_uses_expect = true
let a = emit_expr(e.kids[0]); let b = emit_expr(e.kids[1]); let tol = emit_expr(e.kids[2])
let d = emit_bind(`sub i32 {a.code}, {b.code}`)
let neg = emit_bind(`sub i32 0, {d}`)
let isneg = emit_bind(`icmp slt i32 {d}, 0`)
let ad = emit_bind(`select i1 {isneg}, i32 {neg}, i32 {d}`)
let c = emit_bind(`icmp sle i32 {ad}, {tol.code}`)
let msg = emit_str_const(`{g_src_name}:{itoa(e.line)}: expect_near failed`)
emit_expect_fail(c, msg, a.code, b.code)
return val("0", "void")
}
# The EV2 reflection ABI (the world table), exposed to Ludic so a Ludic mod can
# introspect the world by name — the same functions a foreign mod binds. Emitted
# only for a modding program (ECS + events), so a plain game is unchanged.