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

@ -169,7 +169,7 @@ function p_postfix() -> Node {
else { if is_op("[") { pi = pi + 1; let lo = expr()
if is_op("..") { pi = pi + 1; let sl = node(E_SLICE); sl.a = e; sl.b = lo; sl.c = expr(); eat_op("]"); e = sl } # s[a..b] substring
else { let ix = node(E_INDEX); ix.a = e; ix.b = lo; eat_op("]"); e = ix } }
else { if is_op("(") { let c = node(E_CALL); c.a = e; args_call(c); e = c } else { break } } }
else { if is_op("(") { let c = node(E_CALL); c.a = e; c.line = toks[pi].line; args_call(c); e = c } else { break } } }
}
return e
}
@ -494,9 +494,25 @@ function parse_one_decl() -> void {
if is_id("function") { let f = parse_fn(); if is_export { f.ival = 1 }; push(prog, f); return }
if is_id("extern") { push(prog, parse_extern()); return }
if is_id("entry") { push(prog, parse_main()); return }
if is_id("test") { push(g_tests, parse_test()); return }
perr("expected declaration")
}
# test "name" { ... } — a named test block, collected into g_tests. The block
# runs under a synthetic runner main (see emit_test_runner); `expect` assertions
# inside it record failures. Kept out of `prog` so it never emits as a plain fn.
function parse_test() -> Node {
let ln = toks[pi].line
pi = pi + 1 # past `test`
if toks[pi].kind != TK_STR { perr("expected a \"name\" string after test") }
let n = node(N_TEST)
n.s = toks[pi].text
n.line = ln
pi = pi + 1 # past the name
n.a = block()
return n
}
# lex and parse an imported fragment into `prog`, saving/restoring lexer state
function do_import(rel: pointer) -> void {
let full = path_join(cur_dir, rel)
@ -567,6 +583,7 @@ function parse_program() -> void {
g_uses_query = false
g_uses_reflect = false
g_uses_light = false
g_tests = new []Node
loaded_paths = new []pointer
skipnl()
g_game_name = "Ludic"