feat(testing): built-in test blocks + expect assertions, auto-run with pass/fail (#12)
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:
parent
a71279a7b9
commit
ea2c6ab246
16 changed files with 15911 additions and 14867 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ var g_uses_osrt: bool = false # Os.* (prelude-backed methods) was emitted -> em
|
|||
var g_uses_unicodert: bool = false # Unicode.* was emitted -> emit the UTF-8 runtime
|
||||
var g_uses_fsrt: bool = false # Fs.*/Path.*/Mime.* was emitted -> emit the filesystem runtime
|
||||
var g_uses_datert: bool = false # Date.*/DateTime.* was emitted -> emit the civil<->epoch conversions
|
||||
var g_uses_expect: bool = false # expect/expect_eq/expect_near was emitted -> emit the test-assert globals
|
||||
var g_tests: []Node # test "name" { ... } blocks collected by the parser
|
||||
var g_src_name: pointer = "?" # base name of the source file, for panic/expect file:line messages
|
||||
var g_uses_longstr: bool = false # string(long) / interpolating a long was emitted -> emit fn_long_str
|
||||
|
||||
# loop targets for break/continue (innermost last)
|
||||
|
|
|
|||
|
|
@ -64,6 +64,73 @@ function emit_main(d: Node) -> void {
|
|||
emit("}\n")
|
||||
}
|
||||
|
||||
# ---- the testing framework -------------------------------------------------
|
||||
# A `test "name" { ... }` block lowers to a void function; `expect*` assertions
|
||||
# inside it flip @L_test_fail. A synthetic runner @main runs every test, prints
|
||||
# `ok - name` / `FAIL - name`, a summary, and exits non-zero if any failed.
|
||||
|
||||
# one test block -> a void function @fn__test_<idx> (mirrors emit_fn's shape).
|
||||
function emit_test_fn(t: Node, idx: int) -> void {
|
||||
ll_t = 0; ll_lbl = 0; g_term = false; loc_reset(); nloop = 0
|
||||
ret_ty = "void"
|
||||
let fbody = buf_new()
|
||||
falloc = buf_new()
|
||||
let saved = code
|
||||
code = fbody
|
||||
emit_block(t.a)
|
||||
if not g_term { emit(" br label %ret\n") }
|
||||
emit("ret:\n ret void\n")
|
||||
code = saved
|
||||
emit("define void @fn__test_"); emit(itoa(idx)); emit("() {\nentry:\n")
|
||||
emit(buf_str(falloc))
|
||||
emit(buf_str(fbody))
|
||||
emit("}\n\n")
|
||||
}
|
||||
|
||||
# emit every test body plus the runner @main that drives them.
|
||||
function emit_test_runner() -> void {
|
||||
emith("@L_test_fail = internal global i32 0\n")
|
||||
emith("@.fmt_test_sum = private unnamed_addr constant [28 x i8] c\"== %d passed, %d failed ==\\0A\\00\"\n")
|
||||
var i = 0
|
||||
while i < len(g_tests) { emit_test_fn(g_tests[i], i); i = i + 1 }
|
||||
if g_uses_expect { emith("@.fmt_expect = private unnamed_addr constant [22 x i8] c\"%s (got %d, want %d)\\0A\\00\"\n") }
|
||||
|
||||
ll_t = 0; ll_lbl = 0
|
||||
emit("define i32 @main(i32 %argc, ptr %argv) {\nentry:\n")
|
||||
emit(" store i32 %argc, ptr @L_argc\n")
|
||||
emit(" store ptr %argv, ptr @L_argv\n")
|
||||
emit(" %failed = alloca i32\n store i32 0, ptr %failed\n")
|
||||
if has_ecs() { emit(" call void @rt_init()\n") }
|
||||
i = 0
|
||||
while i < len(g_tests) {
|
||||
let okmsg = emit_str_const(`ok - {g_tests[i].s}`)
|
||||
let failmsg = emit_str_const(`FAIL - {g_tests[i].s}`)
|
||||
emit(" store i32 0, ptr @L_test_fail\n")
|
||||
emit(` call void @fn__test_{itoa(i)}()\n`)
|
||||
let f = emit_bind("load i32, ptr @L_test_fail")
|
||||
let isbad = emit_bind(`icmp ne i32 {f}, 0`)
|
||||
let lpass = lbl("tpass"); let lfail = lbl("tfail"); let ldone = lbl("tdone")
|
||||
emit(` br i1 {isbad}, label %{lfail}, label %{lpass}\n`)
|
||||
emit(`{lpass}:\n`)
|
||||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_line, ptr {okmsg})\n`)
|
||||
emit(` br label %{ldone}\n`)
|
||||
emit(`{lfail}:\n`)
|
||||
let cf = emit_bind("load i32, ptr %failed")
|
||||
let cf1 = emit_bind(`add i32 {cf}, 1`)
|
||||
emit(` store i32 {cf1}, ptr %failed\n`)
|
||||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_line, ptr {failmsg})\n`)
|
||||
emit(` br label %{ldone}\n`)
|
||||
emit(`{ldone}:\n`)
|
||||
i = i + 1
|
||||
}
|
||||
let totf = emit_bind("load i32, ptr %failed")
|
||||
let passed = emit_bind(`sub i32 {itoa(len(g_tests))}, {totf}`)
|
||||
emit(` call i32 (ptr, ...) @printf(ptr @.fmt_test_sum, i32 {passed}, i32 {totf})\n`)
|
||||
let allok = emit_bind(`icmp eq i32 {totf}, 0`)
|
||||
let rc = emit_bind(`select i1 {allok}, i32 0, i32 1`)
|
||||
emit(` ret i32 {rc}\n}\n`)
|
||||
}
|
||||
|
||||
function emit_program() -> void {
|
||||
head = buf_new()
|
||||
code = buf_new()
|
||||
|
|
@ -71,6 +138,7 @@ function emit_program() -> void {
|
|||
g_uses_intstr = false
|
||||
g_uses_strslice = false
|
||||
g_uses_loopback = false
|
||||
g_uses_expect = false
|
||||
loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
|
||||
brk_lbl = new []pointer; cnt_lbl = new []pointer
|
||||
self_stk = new []pointer
|
||||
|
|
@ -85,7 +153,11 @@ function emit_program() -> void {
|
|||
if has_ecs() { emit_ecs_allocator(); emit_snapshot() }
|
||||
if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally)
|
||||
if has_ui() { emit_ui_build() }
|
||||
if has_systems() and has_entry() { # N5: game owns its loop via `entry`
|
||||
if len(g_tests) > 0 { # a test file: synth a runner @main
|
||||
if has_systems() { emit_game_defs() }
|
||||
emit_test_runner()
|
||||
}
|
||||
else { if has_systems() and has_entry() { # N5: game owns its loop via `entry`
|
||||
emit_game_defs() # system fns, hooks, tick helpers
|
||||
i = 0
|
||||
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
|
||||
|
|
@ -94,7 +166,7 @@ function emit_program() -> void {
|
|||
else {
|
||||
i = 0
|
||||
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 }
|
||||
} }
|
||||
} } }
|
||||
if g_uses_loopback { emit_loopback() } # built-in transport, after all net_send/net_poll uses are seen
|
||||
if g_uses_str { emit_str_prelude() } # @fn_str_eq / @fn_str_concat, after all uses are seen
|
||||
if g_uses_intstr { emit_int_str() } # @fn_int_str, for string(int) in interpolation
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const S_EMIT: int = 49 # emit E(field: v, ...) — fire event E (calls
|
|||
# s=event name a=E_REC of named args; also usable as an
|
||||
# expression (a cancellable event returns its cancelled flag)
|
||||
const S_CANCEL: int = 50 # cancel — inside a listener, veto a `cancellable` event
|
||||
const N_TEST: int = 51 # test "name" { ... } — a named test block (the testing framework)
|
||||
# s=name a=body block line=source line of the test
|
||||
# statements
|
||||
const S_LET: int = 10
|
||||
const S_ASSIGN: int = 11
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
30508
selfhost/ludicc.seed.ll
30508
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -110,6 +110,7 @@ entry {
|
|||
if (src == null) { die("ludicc: cannot open input\n") }
|
||||
|
||||
cur_dir = dir_of(path)
|
||||
g_src_name = base_name(path) # for panic/expect file:line messages
|
||||
lex(src)
|
||||
parse_program()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue