// runtime/test.el — El test framework: assertions, registration, and runner. // // Provides a minimal but complete test harness for El programs. No external // dependencies. Written entirely in El using existing runtime primitives. // // ── Quick-start ────────────────────────────────────────────────────────────── // // 1. Write a test function with the standard test signature: // // fn test_str_eq_works(_: String) -> String { // assert_true(str_eq("a", "a"), "same strings are equal") // assert_false(str_eq("a", "b"), "different strings are not equal") // return "" // } // // 2. Register it and run: // // fn main() -> Void { // test_case("str_eq works", "test_str_eq_works") // test_run_all() // } // // Test functions must have the signature (String) -> String. The argument is // a dummy passed by the threading mechanism (see runtime/thread.el) and should // be ignored. The return value is likewise ignored — results flow through the // state-based assertion primitives. // // ── State keys ─────────────────────────────────────────────────────────────── // // _test_cases JSON array of {"name":"...","fn":"..."} // _test_pass_count Int as string — total passing assertions // _test_fail_count Int as string — total failing assertions // _test_failures JSON array of failure message strings // _test_current Name of the test case currently executing // // ── Dependencies ───────────────────────────────────────────────────────────── // // runtime/string.el — str_eq, str_concat, str_contains, int_to_str, ... // runtime/state.el — state_set, state_get // runtime/json.el — json_array_len, json_array_get_string, json_get, // json_escape_string // runtime/thread.el — spawn, join (for dynamic dispatch via dlsym) // ── Internal: JSON array helpers ───────────────────────────────────────────── // // _test_json_append — append a quoted, escaped string element to a JSON array. // // Given an existing JSON array string (e.g. '["a","b"]') and a plain string // value, returns a new array with the value appended (e.g. '["a","b","c"]'). // // The array must be non-empty — always init with "[]" before calling. fn _test_json_append(arr: String, val: String) -> String { let escaped: String = json_escape_string(val) let inner: String = str_slice(arr, 1, str_len(arr) - 1) if str_eq(inner, "") { return "[\"" + escaped + "\"]" } return "[" + inner + ",\"" + escaped + "\"]" } // _test_json_obj_append — append a raw JSON object string to a JSON array. // // Used to build the _test_cases list where each element is already a // JSON object (not a plain string). fn _test_json_obj_append(arr: String, obj: String) -> String { let inner: String = str_slice(arr, 1, str_len(arr) - 1) if str_eq(inner, "") { return "[" + obj + "]" } return "[" + inner + "," + obj + "]" } // ── Test registration ───────────────────────────────────────────────────────── // test_case — register a named test case. // // name: Human-readable test case name (shown in output). // fn_name: Name of a top-level El function with signature // (String) -> String. The function should call assertion // primitives from this module. The String arg it receives is "" // and its return value is ignored. // // Test cases are stored in the _test_cases state key and executed in // registration order by test_run_all(). fn test_case(name: String, fn_name: String) { let arr: String = state_get("_test_cases") if str_eq(arr, "") { arr = "[]" } let escaped_name: String = json_escape_string(name) let escaped_fn: String = json_escape_string(fn_name) let obj: String = "{\"name\":\"" + escaped_name + "\",\"fn\":\"" + escaped_fn + "\"}" let arr = _test_json_obj_append(arr, obj) state_set("_test_cases", arr) } // ── Test state helpers ──────────────────────────────────────────────────────── // _test_init — reset all test counters and failure lists. // // Called at the start of test_run_all(). Safe to call multiple times. fn _test_init() { state_set("_test_pass_count", "0") state_set("_test_fail_count", "0") state_set("_test_failures", "[]") state_set("_test_current", "") } // _test_inc_pass — increment the global pass counter by 1. fn _test_inc_pass() { let n: Int = str_to_int(state_get("_test_pass_count")) state_set("_test_pass_count", int_to_str(n + 1)) } // _test_inc_fail — increment the global fail counter by 1. fn _test_inc_fail() { let n: Int = str_to_int(state_get("_test_fail_count")) state_set("_test_fail_count", int_to_str(n + 1)) } // test_pass — record a passing assertion for the current test case. // // Increments the pass counter. Called internally by assertions. fn test_pass(name: String) { _test_inc_pass() } // test_fail — record a failing assertion for the current test case. // // name: assertion label or description (usually the msg parameter) // msg: detailed failure message including expected/got values // // Increments the fail counter and appends the message to _test_failures. // Also prints the failure immediately for visibility. fn test_fail(name: String, msg: String) { _test_inc_fail() let failures: String = state_get("_test_failures") if str_eq(failures, "") { failures = "[]" } let entry: String = " " + msg let failures = _test_json_append(failures, entry) state_set("_test_failures", failures) println(" FAIL: " + msg) } // ── Assertions ──────────────────────────────────────────────────────────────── // assert_true — assert that condition is true. // // condition: the boolean value to test // msg: description shown on failure fn assert_true(condition: Bool, msg: String) { if condition { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": expected true, got false") } // assert_false — assert that condition is false. // // condition: the boolean value to test // msg: description shown on failure fn assert_false(condition: Bool, msg: String) { if !condition { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": expected false, got true") } // assert_eq — assert that two strings are equal. // // a, b: strings to compare // msg: description shown on failure (quoted values appended automatically) fn assert_eq(a: String, b: String, msg: String) { if str_eq(a, b) { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": expected \"" + b + "\", got \"" + a + "\"") } // assert_int_eq — assert that two integers are equal. // // a, b: integers to compare // msg: description shown on failure fn assert_int_eq(a: Int, b: Int, msg: String) { if a == b { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": expected " + int_to_str(b) + ", got " + int_to_str(a)) } // assert_neq — assert that two strings are NOT equal. // // a, b: strings to compare // msg: description shown on failure fn assert_neq(a: String, b: String, msg: String) { if !str_eq(a, b) { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": expected values to differ, but both are \"" + a + "\"") } // assert_contains — assert that string s contains substring sub. // // s: haystack string // sub: needle substring // msg: description shown on failure fn assert_contains(s: String, sub: String, msg: String) { if str_contains(s, sub) { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": \"" + s + "\" does not contain \"" + sub + "\"") } // assert_starts_with — assert that string s starts with prefix. // // s: string to inspect // prefix: expected prefix // msg: description shown on failure fn assert_starts_with(s: String, prefix: String, msg: String) { if str_starts_with(s, prefix) { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": \"" + s + "\" does not start with \"" + prefix + "\"") } // assert_ends_with — assert that string s ends with suffix. // // s: string to inspect // suffix: expected suffix // msg: description shown on failure fn assert_ends_with(s: String, suffix: String, msg: String) { if str_ends_with(s, suffix) { test_pass(msg) return } let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg + ": \"" + s + "\" does not end with \"" + suffix + "\"") } // fail — unconditional test failure. // // msg: failure message shown in output // // Use when a code path that must not be reached is reached, or when an // expected exception did not occur. fn fail(msg: String) { let test_name: String = state_get("_test_current") test_fail(msg, "[" + test_name + "] " + msg) } // ── Runner ──────────────────────────────────────────────────────────────────── // _test_run_one — execute a single registered test case by name. // // name: the human-readable test case name (set as _test_current) // fn_name: the El function to invoke via the thread mechanism // // Sets _test_current so that assertions inside the test function know which // test they belong to. Spawns and immediately joins the test function in a // child thread (same dlsym mechanism as parallel_map) so dynamic dispatch // works without needing closures. fn _test_run_one(name: String, fn_name: String) { state_set("_test_current", name) let before_fail: Int = str_to_int(state_get("_test_fail_count")) let tid: Int = __thread_create(fn_name, "") __thread_join(tid) let after_fail: Int = str_to_int(state_get("_test_fail_count")) if after_fail == before_fail { println("[test] " + name + " ... PASS") } else { println("[test] " + name + " ... FAIL") } } // test_run_all — execute all registered test cases and print a summary. // // Iterates through every test case registered via test_case(), runs each one, // prints per-test PASS/FAIL status, then prints a summary line. // // Returns the total number of failing assertions. Exit with this value to // signal CI failure: // // fn main() -> Int { // test_case("str_eq", "test_str_eq") // return test_run_all() // } fn test_run_all() -> Int { _test_init() let cases: String = state_get("_test_cases") if str_eq(cases, "") { cases = "[]" } let n: Int = json_array_len(cases) let i: Int = 0 while i < n { let entry: String = json_array_get(cases, i) let name: String = json_get(entry, "name") let fn_name: String = json_get(entry, "fn") _test_run_one(name, fn_name) i = i + 1 } let pass_count: Int = str_to_int(state_get("_test_pass_count")) let fail_count: Int = str_to_int(state_get("_test_fail_count")) println("[test] Summary: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed") return fail_count }