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

@ -0,0 +1,3 @@
bump: minor
type: feat
Built-in testing framework — a `test "name" { … }` block, discovered and run automatically by a synthetic runner (no `entry` to write). Inside a test, `expect(cond)`, `expect_eq(a, b)` and `expect_near(a, b, tol)` assert; on failure they print `file:line: … failed (got …, want …)` and mark the test failed without aborting, 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 and accumulated-integer game math need. Coverage instrumentation is a follow-up.

View file

@ -0,0 +1,7 @@
---
id: testing
title: Testing
order: 34
---
A built-in testing framework in the spirit of Go's <code>go test</code>: tests live next to the code, run with one command, and report pass/fail — no harness to wire up. A <a href="kw-test"><code>test "name" { … }</code></a> block is discovered automatically and run by a synthetic entry point that prints <code>ok - name</code> or <code>FAIL - name</code> for each, a <code>== N passed, M failed ==</code> summary, and exits non-zero if anything failed (so CI and the <code>bin/x</code> runner catch it). Inside a test, the <code>expect</code>, <code>expect_eq</code> and <code>expect_near</code> assertions check a condition and, on failure, print <code>file:line: … failed (got …, want …)</code> and mark the test failed — without aborting, so one run reports every failure. <code>expect_near</code> takes a tolerance, which is what fixed-point and accumulated-integer game math need. Everything is deterministic and compiles to a native binary, so a suite runs in the same C-free toolchain as the rest of Ludic. Coverage instrumentation is a planned follow-up. Related: <a href="kw-function"><code>function</code></a>, <a href="fn-print"><code>print</code></a>.

View file

@ -0,0 +1,36 @@
---
id: kw-test
name: test
category: testing
kind: keyword
tokens: test
sig: test "name" { … }
tip: A named test block, run automatically with pass/fail reporting.
order: 1
---
A <code>test "name" { … }</code> block declares a named test. Every test in a file is discovered automatically and run by a generated entry point — there is no <code>entry</code> to write and nothing to register. The runner prints <code>ok - name</code> for a test whose assertions all held and <code>FAIL - name</code> for one that had a failure, then a <code>== N passed, M failed ==</code> summary, and the program exits non-zero if any test failed. Writing a test should feel like writing a function: put a few assertions in a block and run it.
Assertions (each records a failure and prints <code>file:line: … failed</code> but keeps going, so one run reports every failure):
- `expect(cond)` — the boolean `cond` must be true.
- `expect_eq(a, b)` — `a` must equal `b`; on failure prints `(got a, want b)`.
- `expect_near(a, b, tol)` — `a` must be within `tol` of `b` (absolute). Use it for `fixed`-point results and accumulated integer math, where an exact match is too brittle.
Run a spec file directly with the compiler-runner — `ludic mymath_test.ludic` compiles it to a native binary, runs it, and forwards the pass/fail exit code — so it drops straight into `bin/x` and CI.
```ludic
program MathSpec {
function add(a: int, b: int) -> int { return a + b }
test "addition adds" {
expect_eq(add(2, 3), 5)
expect(add(1, 1) == 2)
}
test "fixed math is close enough" {
let half = fixed(1) / 2 # 0.5 in Q16.16
expect_near(half, 32768, 2) # within 2 raw units of 0.5
}
}
```

View file

@ -0,0 +1,43 @@
# testing.ludic — the built-in testing framework: `test "name" { … }` blocks with
# expect / expect_eq / expect_near assertions, discovered and run automatically by
# a synthetic runner that prints per-test pass/fail, a summary, and exits non-zero
# on any failure. This spec passes, so it prints one `ok - name` per test and:
# == 6 passed, 0 failed ==
# It is a runnable spec (no `entry`), driven by the runner the compiler generates.
program TestingSpec {
function add(a: int, b: int) -> int { return a + b }
function mul(a: int, b: int) -> int { return a * b }
test "expect on a boolean" {
expect(add(1, 1) == 2)
expect(mul(3, 4) == 12)
}
test "expect_eq compares ints" {
expect_eq(add(2, 3), 5)
expect_eq(mul(6, 7), 42)
}
test "expect_near tolerates a delta" {
expect_near(100, 103, 5) # |100 - 103| = 3 <= 5
expect_near(add(10, 10), 19, 1) # 20 within 1 of 19
}
test "fixed-point stays close" {
let half = fixed(1) / 2 # 0.5 in Q16.16 = 32768
expect_near(half, 32768, 0) # exact here, but tolerance is the point
let third = fixed(1) / 3 # ~0.3333
expect_near(third, 21845, 2) # 65536/3 = 21845.33 -> 21845
}
test "loops and locals work in a test" {
var sum = 0
for i in 0 .. 5 { sum = sum + i } # 0+1+2+3+4 = 10
expect_eq(sum, 10)
}
test "strings compare" {
let s = "ab" + "c"
expect(s == "abc")
}
}

View file

@ -4,6 +4,25 @@
# runtime builtin plus the parameter labels callers may use as named arguments; # 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 # 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). # 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 { function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
# Math.* is computed inline (deterministic fixed-point), not routed through a # Math.* is computed inline (deterministic fixed-point), not routed through a
# bare rt_ name — so `floor`/`round`/`lerp` never leak into the bare namespace. # 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 == "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") } 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 # 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 # 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. # only for a modding program (ECS + events), so a plain game is unchanged.

View file

@ -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_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_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_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 var g_uses_longstr: bool = false # string(long) / interpolating a long was emitted -> emit fn_long_str
# loop targets for break/continue (innermost last) # loop targets for break/continue (innermost last)

View file

@ -64,6 +64,73 @@ function emit_main(d: Node) -> void {
emit("}\n") 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 { function emit_program() -> void {
head = buf_new() head = buf_new()
code = buf_new() code = buf_new()
@ -71,6 +138,7 @@ function emit_program() -> void {
g_uses_intstr = false g_uses_intstr = false
g_uses_strslice = false g_uses_strslice = false
g_uses_loopback = 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 loc_name = new []pointer; loc_reg = new []pointer; loc_ty = new []pointer; loc_mut = new []int
brk_lbl = new []pointer; cnt_lbl = new []pointer brk_lbl = new []pointer; cnt_lbl = new []pointer
self_stk = 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_ecs_allocator(); emit_snapshot() }
if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally) if has_ecs() { emit_net() } # N2/N3: @Sync serializers + @Owned storage (gated internally)
if has_ui() { emit_ui_build() } 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 emit_game_defs() # system fns, hooks, tick helpers
i = 0 i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 } 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 { else {
i = 0 i = 0
while i < len(prog) { if prog[i].kind == N_MAIN { emit_main(prog[i]) }; i = i + 1 } 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_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_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 if g_uses_intstr { emit_int_str() } # @fn_int_str, for string(int) in interpolation

View file

@ -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 # s=event name a=E_REC of named args; also usable as an
# expression (a cancellable event returns its cancelled flag) # expression (a cancellable event returns its cancelled flag)
const S_CANCEL: int = 50 # cancel — inside a listener, veto a `cancellable` event 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 # statements
const S_LET: int = 10 const S_LET: int = 10
const S_ASSIGN: int = 11 const S_ASSIGN: int = 11

View file

@ -169,7 +169,7 @@ function p_postfix() -> Node {
else { if is_op("[") { pi = pi + 1; let lo = expr() 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 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 { 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 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("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("extern") { push(prog, parse_extern()); return }
if is_id("entry") { push(prog, parse_main()); return } if is_id("entry") { push(prog, parse_main()); return }
if is_id("test") { push(g_tests, parse_test()); return }
perr("expected declaration") 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 # lex and parse an imported fragment into `prog`, saving/restoring lexer state
function do_import(rel: pointer) -> void { function do_import(rel: pointer) -> void {
let full = path_join(cur_dir, rel) let full = path_join(cur_dir, rel)
@ -567,6 +583,7 @@ function parse_program() -> void {
g_uses_query = false g_uses_query = false
g_uses_reflect = false g_uses_reflect = false
g_uses_light = false g_uses_light = false
g_tests = new []Node
loaded_paths = new []pointer loaded_paths = new []pointer
skipnl() skipnl()
g_game_name = "Ludic" g_game_name = "Ludic"

File diff suppressed because it is too large Load diff

View file

@ -110,6 +110,7 @@ entry {
if (src == null) { die("ludicc: cannot open input\n") } if (src == null) { die("ludicc: cannot open input\n") }
cur_dir = dir_of(path) cur_dir = dir_of(path)
g_src_name = base_name(path) # for panic/expect file:line messages
lex(src) lex(src)
parse_program() parse_program()

View file

@ -42,7 +42,7 @@ object LudicTokens {
object LudicVocabulary { object LudicVocabulary {
val DECL = setOf( val DECL = setOf(
"program", "import", "property", "model", "enum", "ui", "program", "import", "property", "model", "enum", "ui",
"const", "var", "function", "extern", "handler", "entry", "event", "scene" "const", "var", "function", "extern", "handler", "entry", "event", "scene", "test"
) )
val CLAUSE = setOf( val CLAUSE = setOf(
"phase", "query", "on", "cancellable", "public", "layer", "start" "phase", "query", "on", "cancellable", "public", "layer", "start"

View file

@ -176,7 +176,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" }, { "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" }, { "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state)\\b" }, { "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|void)\\b" }, { "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" }, { "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" } { "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -176,7 +176,7 @@
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" }, { "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" }, { "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" }, { "name": "keyword.other.ludic", "match": "\\b(import|extern)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state)\\b" }, { "name": "storage.type.ludic", "match": "\\b(program|property|model|enum|ui|const|var|let|function|handler|entry|state|test)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|void)\\b" }, { "name": "support.type.primitive.ludic", "match": "\\b(int|long|fixed|bool|entity|string|pointer|byte|words|fixeds|pointers|Vector|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" }, { "name": "constant.language.boolean.ludic", "match": "\\b(true|false|null)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" } { "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }

View file

@ -53,7 +53,7 @@ typedef struct {
* declaration keyword, anything stmt() dispatches on is a statement keyword. */ * declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = { static const char* LUDIC_KW_DECL[] = {
"program","import","property","model","enum","ui", "program","import","property","model","enum","ui",
"const","var","function","extern","handler","entry","event","scene", 0 "const","var","function","extern","handler","entry","event","scene","test", 0
}; };
static const char* LUDIC_KW_CLAUSE[] = { static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","on","cancellable","public","layer","start", 0 "phase","query","on","cancellable","public","layer","start", 0

View file

@ -56,6 +56,25 @@ function net_case(path: pointer, exp: pointer) -> void {
if (got == exp) { ok(`{path} ({got})`) } else { bad2(path, `got [{got}] want [{exp}]`) } 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/x_spec_{nm}.ll`
let er = `/tmp/x_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/x_spec_{nm} 2>{er}`) {
bad2(path, capture_line(`tail -1 {er}`)); return
}
let rc = sh(`/tmp/x_spec_{nm} > /tmp/x_spec_{nm}.out 2>&1`)
let summary = capture_line(`tail -1 /tmp/x_spec_{nm}.out`)
if (rc == 0) and (summary == exp) { ok(`{path} ({summary})`) }
else { bad2(path, `rc={string(rc)} last=[{summary}]`) }
}
function cmd_test() -> int { function cmd_test() -> int {
PASS = 0 PASS = 0
FAIL = 0 FAIL = 0
@ -109,6 +128,7 @@ function cmd_test() -> int {
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/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/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/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/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)")
spec_case("library/testing", "== 6 passed, 0 failed ==")
feat_case("library/logging", "", "0 5 2 1", "logging.ludic (Log levels, set_level/level threshold, structured fields)") 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 # 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). # their asserted values are macOS-specific; skip off Darwin (see is_darwin).