Skip to main content

seq_runtime/
test.rs

1//! Test framework support for Seq
2//!
3//! Provides assertion primitives and test context management for the `seqc test` runner.
4//! Assertions collect failures instead of panicking, allowing all tests to run and
5//! report comprehensive results.
6//!
7//! These functions are exported with C ABI for LLVM codegen to call.
8
9use crate::stack::{Stack, pop, push};
10use crate::value::Value;
11use std::sync::Mutex;
12
13/// Render a stack `Value` for a failure message — prefers the natural
14/// form for `Int` / `Bool`, falls back to debug for anything else.
15fn display_value(val: &Value) -> String {
16    match val {
17        Value::Bool(b) => b.to_string(),
18        Value::Int(n) => n.to_string(),
19        other => format!("{:?}", other),
20    }
21}
22
23/// Maximum number of per-test assertion failures to print in the run
24/// summary. Additional failures are rolled up into a `+N more failure(s)`
25/// footer so noisy tests (loop-like assertions over lists) don't drown
26/// the overall report. Tune here if feedback suggests a different value.
27const MAX_PRINTED_FAILURES_PER_TEST: usize = 5;
28
29/// A single test failure with context
30#[derive(Debug, Clone)]
31pub struct TestFailure {
32    /// Source line of the assertion (1-indexed), if codegen set one.
33    pub line: Option<u32>,
34    pub message: String,
35    pub expected: Option<String>,
36    pub actual: Option<String>,
37}
38
39/// Test context that tracks assertion results
40#[derive(Debug, Default)]
41pub struct TestContext {
42    /// Current test name being executed
43    pub current_test: Option<String>,
44    /// Source line of the assertion most recently announced by codegen.
45    /// Set by `patch_seq_test_set_line` just before each `test.assert*`
46    /// call; captured into a `TestFailure` if the assertion fails.
47    pub current_line: Option<u32>,
48    /// Number of passed assertions
49    pub passes: usize,
50    /// Collected failures
51    pub failures: Vec<TestFailure>,
52}
53
54impl TestContext {
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    pub fn reset(&mut self, test_name: Option<String>) {
60        self.current_test = test_name;
61        self.current_line = None;
62        self.passes = 0;
63        self.failures.clear();
64    }
65
66    pub fn record_pass(&mut self) {
67        self.passes += 1;
68        // Consume the line so a following assertion without a `set_line`
69        // hook (defensive — span-less `WordCall`s don't emit one) can't
70        // inherit this one's attribution.
71        self.current_line = None;
72    }
73
74    pub fn record_failure(
75        &mut self,
76        message: String,
77        expected: Option<String>,
78        actual: Option<String>,
79    ) {
80        self.failures.push(TestFailure {
81            line: self.current_line,
82            message,
83            expected,
84            actual,
85        });
86        // Same rationale as `record_pass`: don't let this line bleed into
87        // the next assertion's record.
88        self.current_line = None;
89    }
90
91    pub fn has_failures(&self) -> bool {
92        !self.failures.is_empty()
93    }
94}
95
96/// Global test context protected by mutex
97static TEST_CONTEXT: Mutex<TestContext> = Mutex::new(TestContext {
98    current_test: None,
99    current_line: None,
100    passes: 0,
101    failures: Vec::new(),
102});
103
104/// Announce the source line of the next `test.assert*` call.
105///
106/// Called by generated code immediately before each assertion so the
107/// runtime can attribute a failure to its source position. `line` is
108/// 1-indexed; pass 0 to clear.
109///
110/// This helper takes a raw `i64` rather than a stack argument because it
111/// is a compiler-emitted diagnostic, not a user-callable Seq builtin.
112///
113/// # Safety
114///
115/// Safe to call from any thread. Acquires the global test-context
116/// mutex; no other preconditions.
117#[unsafe(no_mangle)]
118pub unsafe extern "C" fn patch_seq_test_set_line(line: i64) {
119    let mut ctx = TEST_CONTEXT.lock().unwrap();
120    // Reject 0 (the agreed "clear" sentinel) and any value that can't
121    // fit in a u32 (no real source file has 4B lines, but be explicit
122    // about truncation intent rather than silently wrapping).
123    ctx.current_line = if line > 0 {
124        u32::try_from(line).ok()
125    } else {
126        None
127    };
128}
129
130/// Set the current test's display name without touching any other state.
131///
132/// Used by the `seqc test` runner to reassert the word-level test name
133/// after the user's test word has run, in case the user called
134/// `test.init "friendly name"` internally and overwrote the header.
135/// Unlike `test.init`, this does NOT clear `failures`, `passes`, or
136/// `current_line`.
137///
138/// Stack effect: ( ..a String -- ..a )
139///
140/// # Safety
141/// Stack must have a String (test name) on top.
142#[unsafe(no_mangle)]
143pub unsafe extern "C" fn patch_seq_test_set_name(stack: Stack) -> Stack {
144    unsafe {
145        let (stack, name_val) = pop(stack);
146        let name = match name_val {
147            Value::String(s) => s.as_str().to_string(),
148            _ => panic!("test.set-name: expected String (test name) on stack"),
149        };
150        let mut ctx = TEST_CONTEXT.lock().unwrap();
151        ctx.current_test = Some(name);
152        stack
153    }
154}
155
156/// Initialize test context for a new test
157///
158/// Stack effect: ( name -- )
159///
160/// # Safety
161/// Stack must have a String (test name) on top
162#[unsafe(no_mangle)]
163pub unsafe extern "C" fn patch_seq_test_init(stack: Stack) -> Stack {
164    unsafe {
165        let (stack, name_val) = pop(stack);
166        let name = match name_val {
167            Value::String(s) => s.as_str().to_string(),
168            _ => panic!("test.init: expected String (test name) on stack"),
169        };
170
171        let mut ctx = TEST_CONTEXT.lock().unwrap();
172        ctx.reset(Some(name));
173        stack
174    }
175}
176
177/// Finalize test and print results
178///
179/// Stack effect: ( -- )
180///
181/// Prints pass/fail summary for the current test in a format parseable by the test runner.
182/// Output format: "test-name ... ok" or "test-name ... FAILED"
183///
184/// # Safety
185/// Stack pointer must be valid
186#[unsafe(no_mangle)]
187pub unsafe extern "C" fn patch_seq_test_finish(stack: Stack) -> Stack {
188    let ctx = TEST_CONTEXT.lock().unwrap();
189    let test_name = ctx.current_test.as_deref().unwrap_or("unknown");
190
191    if ctx.failures.is_empty() {
192        // Output pass in parseable format
193        println!("{} ... ok", test_name);
194    } else {
195        // Output failure in parseable format. Detail lines are emitted on
196        // STDOUT, indented, so the test runner can associate them with the
197        // preceding FAILED header on the same stream.
198        // Cap the per-test output so a flood of failures (e.g. a loop-like
199        // test walking a list) doesn't drown the summary. The first
200        // `MAX_PRINTED_FAILURES_PER_TEST` are printed in full; a footer
201        // counts anything suppressed.
202        println!("{} ... FAILED", test_name);
203        for failure in ctx.failures.iter().take(MAX_PRINTED_FAILURES_PER_TEST) {
204            let detail = match (&failure.expected, &failure.actual) {
205                (Some(e), Some(a)) => format!("expected {}, got {}", e, a),
206                _ => failure.message.clone(),
207            };
208            match failure.line {
209                Some(line) => println!("  at line {}: {}", line, detail),
210                None => println!("  {}", detail),
211            }
212        }
213        if ctx.failures.len() > MAX_PRINTED_FAILURES_PER_TEST {
214            let remaining = ctx.failures.len() - MAX_PRINTED_FAILURES_PER_TEST;
215            let s = if remaining == 1 { "" } else { "s" };
216            println!("  +{} more failure{}", remaining, s);
217        }
218    }
219
220    stack
221}
222
223/// Check if any assertions failed
224///
225/// Stack effect: ( -- Int )
226///
227/// Returns 1 if there are failures, 0 if all passed.
228///
229/// # Safety
230/// Stack pointer must be valid
231#[unsafe(no_mangle)]
232pub unsafe extern "C" fn patch_seq_test_has_failures(stack: Stack) -> Stack {
233    let ctx = TEST_CONTEXT.lock().unwrap();
234    let has_failures = ctx.has_failures();
235    unsafe { push(stack, Value::Bool(has_failures)) }
236}
237
238/// Assert that a value is truthy (non-zero)
239///
240/// Stack effect: ( Int -- )
241///
242/// Records failure if value is 0, records pass otherwise.
243///
244/// # Safety
245/// Stack must have an Int on top
246#[unsafe(no_mangle)]
247pub unsafe extern "C" fn patch_seq_test_assert(stack: Stack) -> Stack {
248    unsafe {
249        let (stack, val) = pop(stack);
250        let condition = match val {
251            Value::Int(n) => n != 0,
252            Value::Bool(b) => b,
253            _ => panic!("test.assert: expected Int or Bool on stack, got {:?}", val),
254        };
255
256        let mut ctx = TEST_CONTEXT.lock().unwrap();
257        if condition {
258            ctx.record_pass();
259        } else {
260            ctx.record_failure(
261                "assertion failed".to_string(),
262                Some("true".to_string()),
263                Some(display_value(&val)),
264            );
265        }
266
267        stack
268    }
269}
270
271/// Assert that a value is falsy (zero)
272///
273/// Stack effect: ( Int -- )
274///
275/// Records failure if value is non-zero, records pass otherwise.
276///
277/// # Safety
278/// Stack must have an Int on top
279#[unsafe(no_mangle)]
280pub unsafe extern "C" fn patch_seq_test_assert_not(stack: Stack) -> Stack {
281    unsafe {
282        let (stack, val) = pop(stack);
283        let is_falsy = match val {
284            Value::Int(n) => n == 0,
285            Value::Bool(b) => !b,
286            _ => panic!(
287                "test.assert-not: expected Int or Bool on stack, got {:?}",
288                val
289            ),
290        };
291
292        let mut ctx = TEST_CONTEXT.lock().unwrap();
293        if is_falsy {
294            ctx.record_pass();
295        } else {
296            ctx.record_failure(
297                "assertion failed".to_string(),
298                Some("false".to_string()),
299                Some(display_value(&val)),
300            );
301        }
302
303        stack
304    }
305}
306
307/// Assert that two integers are equal
308///
309/// Stack effect: ( expected actual -- )
310///
311/// Records failure if values differ, records pass otherwise.
312///
313/// # Safety
314/// Stack must have two Ints on top
315#[unsafe(no_mangle)]
316pub unsafe extern "C" fn patch_seq_test_assert_eq(stack: Stack) -> Stack {
317    unsafe {
318        let (stack, actual_val) = pop(stack);
319        let (stack, expected_val) = pop(stack);
320
321        let (expected, actual) = match (&expected_val, &actual_val) {
322            (Value::Int(e), Value::Int(a)) => (*e, *a),
323            _ => panic!(
324                "test.assert-eq: expected two Ints on stack, got {:?} and {:?}",
325                expected_val, actual_val
326            ),
327        };
328
329        let mut ctx = TEST_CONTEXT.lock().unwrap();
330        if expected == actual {
331            ctx.record_pass();
332        } else {
333            ctx.record_failure(
334                "assertion failed: values not equal".to_string(),
335                Some(expected.to_string()),
336                Some(actual.to_string()),
337            );
338        }
339
340        stack
341    }
342}
343
344/// Assert that two strings are equal
345///
346/// Stack effect: ( expected actual -- )
347///
348/// Records failure if strings differ, records pass otherwise.
349///
350/// # Safety
351/// Stack must have two Strings on top
352#[unsafe(no_mangle)]
353pub unsafe extern "C" fn patch_seq_test_assert_eq_str(stack: Stack) -> Stack {
354    unsafe {
355        let (stack, actual_val) = pop(stack);
356        let (stack, expected_val) = pop(stack);
357
358        let (expected, actual) = match (&expected_val, &actual_val) {
359            (Value::String(e), Value::String(a)) => {
360                (e.as_str().to_string(), a.as_str().to_string())
361            }
362            _ => panic!(
363                "test.assert-eq-str: expected two Strings on stack, got {:?} and {:?}",
364                expected_val, actual_val
365            ),
366        };
367
368        let mut ctx = TEST_CONTEXT.lock().unwrap();
369        if expected == actual {
370            ctx.record_pass();
371        } else {
372            ctx.record_failure(
373                "assertion failed: strings not equal".to_string(),
374                Some(format!("\"{}\"", expected)),
375                Some(format!("\"{}\"", actual)),
376            );
377        }
378
379        stack
380    }
381}
382
383/// Explicitly fail a test with a message
384///
385/// Stack effect: ( message -- )
386///
387/// Always records a failure with the given message.
388///
389/// # Safety
390/// Stack must have a String on top
391#[unsafe(no_mangle)]
392pub unsafe extern "C" fn patch_seq_test_fail(stack: Stack) -> Stack {
393    unsafe {
394        let (stack, msg_val) = pop(stack);
395        let message = match msg_val {
396            Value::String(s) => s.as_str().to_string(),
397            _ => panic!("test.fail: expected String (message) on stack"),
398        };
399
400        let mut ctx = TEST_CONTEXT.lock().unwrap();
401        ctx.record_failure(message, None, None);
402
403        stack
404    }
405}
406
407/// Get the number of passed assertions
408///
409/// Stack effect: ( -- Int )
410///
411/// # Safety
412/// Stack pointer must be valid
413#[unsafe(no_mangle)]
414pub unsafe extern "C" fn patch_seq_test_pass_count(stack: Stack) -> Stack {
415    let ctx = TEST_CONTEXT.lock().unwrap();
416    unsafe { push(stack, Value::Int(ctx.passes as i64)) }
417}
418
419/// Get the number of failed assertions
420///
421/// Stack effect: ( -- Int )
422///
423/// # Safety
424/// Stack pointer must be valid
425#[unsafe(no_mangle)]
426pub unsafe extern "C" fn patch_seq_test_fail_count(stack: Stack) -> Stack {
427    let ctx = TEST_CONTEXT.lock().unwrap();
428    unsafe { push(stack, Value::Int(ctx.failures.len() as i64)) }
429}
430
431#[cfg(test)]
432mod tests;