321 lines
12 KiB
EmacsLisp
321 lines
12 KiB
EmacsLisp
// 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
|
|
}
|