1
//! End-to-end tests for `(catch-each items var body)` (ADR-0025).
2
//!
3
//! Each form goes through the full Session pipeline: compiler emits the
4
//! lambda + `__nomi_catch_each` import, host fn walks the chain in
5
//! Rust, per-item errors classify into `(err code msg)` cells, engine
6
//! deadlines (fuel / epoch) re-throw past the iteration boundary.
7
//!
8
//! Result cells are heterogeneous `pair<anyref>` chains, which the host
9
//! renders as `<anyref>` placeholders. Script-side digestion via
10
//! `length` is the precise probe for cell count; the wire envelope
11
//! shape (`:value` vs `:error`) confirms whether script-raised errors
12
//! were captured (good) or bubbled past `catch-each` (bug). Engine
13
//! deadlines must surface as a top-level `:error`, never as a `:value`.
14
//!
15
//! Per-cell content (`(ok x)` vs `(err code msg)`) is unit-tested in
16
//! `rpc::natives::catch_each::tests` against the host's
17
//! `err_code_and_message` directly — re-asserting it through the
18
//! placeholder-rendered wire would require a script-side downcast that
19
//! the static type system intentionally refuses.
20

            
21
use rpc::{ScriptCtx, ScriptLimits, Session};
22
use uuid::Uuid;
23

            
24
5
async fn rpc_response(form: &str) -> String {
25
5
    let mut session = Session::new(ScriptCtx::new(Uuid::new_v4())).unwrap();
26
5
    session.handle_form(&format!("(:id 7 :form {form})")).await
27
5
}
28

            
29
1
async fn rpc_response_with_fuel(form: &str, fuel: u64) -> String {
30
1
    let ctx = ScriptCtx::new(Uuid::new_v4()).with_limits(ScriptLimits {
31
1
        fuel,
32
1
        max_memory_pages: 64,
33
1
    });
34
1
    let mut session = Session::new(ctx).unwrap();
35
1
    session.handle_form(&format!("(:id 8 :form {form})")).await
36
1
}
37

            
38
4
fn extract_value(response: &str) -> &str {
39
4
    response
40
4
        .split_once(":value ")
41
4
        .map(|(_, rest)| rest.trim_end_matches(')').trim())
42
4
        .unwrap_or(response)
43
4
}
44

            
45
#[tokio::test(flavor = "current_thread")]
46
1
async fn empty_items_produces_zero_cells() {
47
1
    let resp = rpc_response("(fold (lambda (acc cell) (+ acc 1)) 0 (catch-each (list) x x))").await;
48
1
    assert!(!resp.contains(":error"), "{resp}");
49
1
    assert_eq!(extract_value(&resp), "0");
50
1
}
51

            
52
#[tokio::test(flavor = "current_thread")]
53
1
async fn all_ok_produces_one_cell_per_input_item() {
54
    // Items are fractional Numbers so `(list ...)` rides `pair<ratio>`;
55
    // the body's `x` then binds at Ratio without crossing the int↔ratio
56
    // boundary the type system forbids.
57
1
    let resp = rpc_response(
58
1
        "(fold (lambda (acc cell) (+ acc 1)) 0 (catch-each (list 11/10 21/10 31/10) x x))",
59
1
    )
60
1
    .await;
61
1
    assert!(!resp.contains(":error"), "{resp}");
62
1
    assert_eq!(extract_value(&resp), "3");
63
1
}
64

            
65
#[tokio::test(flavor = "current_thread")]
66
1
async fn mixed_outcomes_keep_cell_count_equal_to_input_count() {
67
    // The body raises on item 2; the host catches it as an err cell
68
    // and resumes the iteration. The wire envelope must be a `:value`,
69
    // not an `:error`, and the chain length must be 3 — proof that the
70
    // raise was captured rather than bubbled past `catch-each`.
71
1
    let resp = rpc_response(
72
1
        "(fold (lambda (acc cell) (+ acc 1)) 0 \
73
1
            (catch-each (list 11/10 21/10 31/10) x \
74
1
                (if (= x 21/10) (error 'oops \"bad\") x)))",
75
1
    )
76
1
    .await;
77
1
    assert!(!resp.contains(":error"), "{resp}");
78
1
    assert_eq!(extract_value(&resp), "3");
79
1
}
80

            
81
#[tokio::test(flavor = "current_thread")]
82
1
async fn all_err_produces_one_cell_per_input_item() {
83
    // The body's only path is `(error ...)` so its return type doesn't
84
    // constrain the items list element type — any element type works.
85
1
    let resp = rpc_response(
86
1
        "(fold (lambda (acc cell) (+ acc 1)) 0 \
87
1
            (catch-each (list 11/10 21/10 31/10) x (error 'fail \"each\")))",
88
1
    )
89
1
    .await;
90
1
    assert!(!resp.contains(":error"), "{resp}");
91
1
    assert_eq!(extract_value(&resp), "3");
92
1
}
93

            
94
#[tokio::test(flavor = "current_thread")]
95
1
async fn script_raised_error_does_not_propagate_to_wire_envelope() {
96
    // The single-element variant: a body that always raises must still
97
    // produce a successful wire response — the captured error rides
98
    // inside the result list, not as a top-level `:error`. This is the
99
    // load-bearing invariant that lets a batch script walk N items and
100
    // report per-item failures structurally.
101
1
    let resp =
102
1
        rpc_response(r#"(catch-each (list 11/10) x (error 'no-such-account "id=42"))"#).await;
103
1
    assert!(
104
1
        !resp.contains(":error"),
105
        "script-raise must be captured, not bubbled: {resp}"
106
    );
107
1
    assert!(resp.contains(":value"), "{resp}");
108
1
}
109

            
110
#[tokio::test(flavor = "current_thread")]
111
1
async fn engine_deadline_propagates_past_catch_each() {
112
    // A genuinely-runaway body must escape catch-each rather than being
113
    // captured as an err cell. Self-recursion without a base case burns
114
    // through the fuel budget; the wire surface is a top-level
115
    // `:code runtime` envelope (OutOfFuel), not a `:value` chain. This
116
    // is the rule that prevents a script from wrapping an infinite loop
117
    // in catch-each to defeat the per-Session deadline.
118
1
    let form = r#"(begin
119
1
                     (defun forever (n) (forever (+ n 1)))
120
1
                     (catch-each (list 11/10 21/10 31/10) x (forever x)))"#;
121
1
    let resp = rpc_response_with_fuel(form, 50_000).await;
122
1
    assert!(
123
1
        resp.contains(":error"),
124
        "expected engine error, got: {resp}"
125
    );
126
1
    let unswallowed = resp.contains(":code runtime") || resp.contains(":code interrupted");
127
1
    assert!(
128
1
        unswallowed,
129
1
        "deadline must propagate, not appear as err cell: {resp}"
130
1
    );
131
1
}