1
//! Tests for the shared cli-core eval surface (ADR-0021).
2

            
3
mod builder;
4
mod mutation_forms;
5

            
6
use nomiscript::{Pair, Reader, list_to_vec};
7
use num_rational::Rational64;
8
use rpc::try_expr_to_value;
9
use sqlx::types::Uuid;
10

            
11
use super::*;
12

            
13
16
fn parse_value(s: &str) -> Value {
14
16
    let program = Reader::parse(s).expect("parse");
15
16
    let expr = program.exprs.into_iter().next().expect("one expr");
16
16
    try_expr_to_value(expr).expect("to value")
17
16
}
18

            
19
#[test]
20
1
fn escape_str_plain() {
21
1
    assert_eq!(escape_str("hello"), r#""hello""#);
22
1
}
23

            
24
#[test]
25
1
fn plist_field_raw_extracts_string_bytes_and_nil() {
26
1
    let v = parse_value("(:config-value \"hi\")");
27
1
    assert!(matches!(plist_field_raw(&v, ":config-value"), Some(Value::String(s)) if s == "hi"));
28

            
29
1
    let b = parse_value("(:config-value #u8(1 2 255))");
30
1
    assert!(
31
1
        matches!(plist_field_raw(&b, ":config-value"), Some(Value::Bytes(by)) if by == vec![1, 2, 255]),
32
        "blob config value must stay Bytes, not a formatted literal"
33
    );
34

            
35
1
    let n = parse_value("(:config-value nil)");
36
1
    assert!(matches!(
37
1
        plist_field_raw(&n, ":config-value"),
38
        Some(Value::Nil)
39
    ));
40

            
41
1
    assert!(plist_field_raw(&v, ":absent").is_none());
42
1
}
43

            
44
#[test]
45
1
fn escape_str_quotes_and_backslash() {
46
1
    assert_eq!(escape_str(r#"a"b\c"#), r#""a\"b\\c""#);
47
1
}
48

            
49
#[test]
50
1
fn escape_str_empty() {
51
1
    assert_eq!(escape_str(""), r#""""#);
52
1
}
53

            
54
#[test]
55
1
fn escape_str_paren_passthrough() {
56
    // Parens are not meta inside a string literal — they must survive verbatim.
57
1
    assert_eq!(escape_str("a(b)c"), r#""a(b)c""#);
58
1
}
59

            
60
#[test]
61
1
fn next_id_increments() {
62
1
    let a = next_id();
63
1
    let b = next_id();
64
1
    assert!(b > a);
65
1
}
66

            
67
#[test]
68
1
fn account_list_line_extracts_name() {
69
1
    let element = parse_value(r#"(:account :id "71d7" :name "acct-alpha" :parent "")"#);
70
1
    assert_eq!(account_list_line(&element), Some("acct-alpha".to_string()));
71
1
}
72

            
73
#[test]
74
1
fn commodity_list_line_formats_symbol_and_name() {
75
1
    let element = parse_value(r#"(:commodity :id "cd8d" :symbol "USD" :name "US Dollar")"#);
76
1
    assert_eq!(
77
1
        commodity_list_line(&element),
78
1
        Some("USD - US Dollar".to_string())
79
    );
80
1
}
81

            
82
#[test]
83
1
fn plist_field_absent_key_is_none() {
84
1
    let element = parse_value(r#"(:account :id "x" :name "y" :parent "")"#);
85
1
    assert_eq!(plist_field(&element, ":symbol"), None);
86
1
}
87

            
88
#[test]
89
1
fn plist_field_config_value() {
90
1
    let plist = parse_value(r#"(:config-value "en")"#);
91
1
    assert_eq!(plist_field(&plist, ":config-value"), Some("en".to_string()));
92
1
}
93

            
94
#[test]
95
1
fn server_error_wire_maps_to_command_error() {
96
1
    let wire = r#"(:id 1 :error (:code args :message "boom"))"#;
97
1
    let err = parse_wire(wire).expect_err("error frame must be Err");
98
1
    let cmd = render_error_to_command(err);
99
1
    assert!(matches!(cmd, CommandError::Command(ref m) if m.contains("boom")));
100
1
}
101

            
102
#[test]
103
1
fn value_truthy_maps_i32_success_to_true() {
104
1
    assert!(value_truthy(&Value::Number(
105
1
        nomiscript::Fraction::from_integer(1)
106
1
    )));
107
1
    assert!(!value_truthy(&Value::Number(
108
1
        nomiscript::Fraction::from_integer(0)
109
1
    )));
110
1
    assert!(value_truthy(&Value::Bool(true)));
111
1
    assert!(!value_truthy(&Value::Bool(false)));
112
1
}
113

            
114
#[test]
115
1
fn adversarial_name_round_trips_through_escape() {
116
1
    let name = r#"weird"(name)"#;
117
1
    let form = format!(
118
        "(:account :id \"x\" :name {} :parent \"\")",
119
1
        escape_str(name)
120
    );
121
1
    let element = parse_value(&form);
122
1
    assert_eq!(account_list_line(&element), Some(name.to_string()));
123
1
}
124

            
125
#[test]
126
1
fn entity_list_with_two_elements_is_a_proper_list() {
127
1
    let v = parse_value(
128
1
        r#"((:account :id "a" :name "one" :parent "") (:account :id "b" :name "two" :parent ""))"#,
129
    );
130
1
    let elements = list_to_vec(&v).expect("proper list");
131
1
    assert_eq!(elements.len(), 2);
132
1
    let lines: Vec<_> = elements.iter().filter_map(account_list_line).collect();
133
1
    assert_eq!(lines, vec!["one".to_string(), "two".to_string()]);
134
1
    let single = Pair::cons(elements[0].clone(), Value::Nil);
135
1
    assert_eq!(list_to_vec(&single).expect("single").len(), 1);
136
1
}
137

            
138
#[test]
139
1
fn transaction_list_line_uses_note_when_present() {
140
1
    let el = parse_value(
141
1
        r#"(:transaction :id "aaa-bbb" :note "my-note" :post-date "2026-01-15T00:00:00+00:00")"#,
142
    );
143
1
    assert_eq!(
144
1
        transaction_list_line(&el),
145
1
        Some("my-note - 2026-01-15T00:00:00+00:00".to_string())
146
    );
147
1
}
148

            
149
#[test]
150
1
fn transaction_list_line_falls_back_to_id_when_note_empty() {
151
1
    let el = parse_value(
152
1
        r#"(:transaction :id "aaa-bbb" :note "" :post-date "2026-01-15T00:00:00+00:00")"#,
153
    );
154
1
    assert_eq!(
155
1
        transaction_list_line(&el),
156
1
        Some("aaa-bbb - 2026-01-15T00:00:00+00:00".to_string())
157
    );
158
1
}
159

            
160
#[test]
161
1
fn parse_balance_element_single_currency() {
162
1
    let inner =
163
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num -50 :value-denom 1)"#;
164
1
    let el = Value::String(inner.to_string());
165
1
    let (r, symbol, name) = parse_balance_element(&el, inner).unwrap();
166
1
    assert_eq!(r, Rational64::new(-50, 1));
167
1
    assert_eq!(symbol, "USD");
168
1
    assert_eq!(name, "US Dollar");
169
1
}
170

            
171
#[test]
172
1
fn build_create_transaction_form_single_currency_no_prices() {
173
1
    let from = Uuid::new_v4();
174
1
    let to = Uuid::new_v4();
175
1
    let currency = Uuid::new_v4();
176
1
    let value = Rational64::new(100, 1);
177
1
    let form = build_create_transaction_form(from, to, currency, currency, value, None, None)
178
1
        .expect("same-currency build");
179
1
    assert!(form.starts_with("(create-transaction "));
180
1
    assert!(form.contains(":splits"));
181
1
    assert!(
182
1
        !form.contains(":prices"),
183
        "same currency must not emit :prices"
184
    );
185
1
    assert!(form.contains(":value -100"), "from-split must be negative");
186
1
    assert!(form.contains(":value 100"), "to-split must be positive");
187
1
    assert!(form.contains(":enter-date"), "must emit :enter-date");
188
3
    let ts = |key: &str| {
189
3
        let after = form.split(&format!("{key} \\\"")).nth(1)?;
190
3
        after.split("\\\"").next().map(str::to_string)
191
3
    };
192
1
    assert_eq!(
193
1
        ts(":post-date"),
194
1
        ts(":enter-date"),
195
        "enter_date must equal post_date"
196
    );
197
1
    assert!(ts(":post-date").is_some());
198
1
}
199

            
200
#[test]
201
1
fn build_create_transaction_form_cross_currency_emits_prices() {
202
1
    let from = Uuid::new_v4();
203
1
    let to = Uuid::new_v4();
204
1
    let usd = Uuid::new_v4();
205
1
    let jpy = Uuid::new_v4();
206
1
    let value = Rational64::new(100, 1);
207
1
    let to_amount = Rational64::new(15000, 1);
208
1
    let form =
209
1
        build_create_transaction_form(from, to, usd, jpy, value, Some(to_amount), Some("lunch"))
210
1
            .expect("cross-currency build");
211
1
    assert!(form.contains(":prices"), "cross-currency must emit :prices");
212
1
    assert!(
213
1
        form.contains(":note \\\"lunch\\\""),
214
        "note must be embedded (escaped), got: {form}"
215
    );
216
    // 100/15000 reduces to the canonical 1/150 (same FX ratio, gcd-divided).
217
1
    assert!(
218
1
        form.contains(":value-num 1 :value-denom 150"),
219
        "reduced FX rate must be 1/150, got: {form}"
220
    );
221
1
}
222

            
223
#[test]
224
1
fn build_create_transaction_form_cross_currency_without_to_amount_errors() {
225
1
    let from = Uuid::new_v4();
226
1
    let to = Uuid::new_v4();
227
1
    let usd = Uuid::new_v4();
228
1
    let jpy = Uuid::new_v4();
229
1
    let value = Rational64::new(100, 1);
230
1
    let err = build_create_transaction_form(from, to, usd, jpy, value, None, None)
231
1
        .expect_err("cross-currency without to_amount must error");
232
1
    assert!(
233
1
        matches!(err, CommandError::Argument(ref m) if m.contains("to_amount")),
234
        "expected to_amount argument error, got: {err:?}"
235
    );
236
1
}
237

            
238
#[test]
239
1
fn ssh_key_list_line_all_fields() {
240
1
    let el = parse_value(
241
1
        r#"(:ssh-key :id "550e" :fingerprint "SHA256:abc" :name "laptop" :key-type "ssh-ed25519" :created-at "2026-05-01T12:00:00+00:00" :last-used-at "2026-06-01T08:00:00+00:00")"#,
242
    );
243
1
    let line = ssh_key_list_line(&el).expect("should render");
244
1
    assert!(line.contains("laptop"), "label should be annotation");
245
1
    assert!(line.contains("SHA256:abc"), "fingerprint must appear");
246
1
    assert!(line.contains("ssh-ed25519"), "key type must appear");
247
1
    assert!(
248
1
        line.contains("2026-05-01T12:00:00+00:00"),
249
        "created-at must appear"
250
    );
251
1
    assert!(
252
1
        line.contains("2026-06-01T08:00:00+00:00"),
253
        "last-used-at must appear"
254
    );
255
1
}
256

            
257
#[test]
258
1
fn ssh_key_list_line_empty_name_falls_back_to_fingerprint() {
259
1
    let el = parse_value(
260
1
        r#"(:ssh-key :id "550e" :fingerprint "SHA256:xyz" :name "" :key-type "ssh-rsa" :created-at "2026-01-01T00:00:00+00:00" :last-used-at "")"#,
261
    );
262
1
    let line = ssh_key_list_line(&el).expect("should render");
263
1
    assert!(
264
1
        line.starts_with("SHA256:xyz"),
265
        "fingerprint should be label when name empty, got: {line}"
266
    );
267
1
    assert!(
268
1
        line.contains("never"),
269
        "empty last-used-at should render as 'never'"
270
    );
271
1
}
272

            
273
#[test]
274
1
fn ssh_key_list_line_missing_fingerprint_returns_none() {
275
1
    let el = parse_value(r#"(:account :id "x" :name "y" :parent "")"#);
276
1
    assert_eq!(
277
1
        ssh_key_list_line(&el),
278
        None,
279
        "missing :fingerprint must return None"
280
    );
281
1
}
282

            
283
#[test]
284
1
fn ssh_key_list_line_missing_required_field_returns_none() {
285
    // :key-type and :created-at are contract-required — their absence signals a
286
    // wire-shape regression and must surface as None, not an empty-bracket line.
287
1
    let no_type = parse_value(
288
1
        r#"(:ssh-key :fingerprint "SHA256:abc" :created-at "2026-01-01T00:00:00+00:00")"#,
289
    );
290
1
    assert_eq!(
291
1
        ssh_key_list_line(&no_type),
292
        None,
293
        "missing :key-type → None"
294
    );
295
1
    let no_created = parse_value(r#"(:ssh-key :fingerprint "SHA256:abc" :key-type "ssh-rsa")"#);
296
1
    assert_eq!(
297
1
        ssh_key_list_line(&no_created),
298
        None,
299
        "missing :created-at → None"
300
    );
301
1
}
302

            
303
#[test]
304
1
fn parse_balance_element_rejects_zero_denominator() {
305
1
    let inner =
306
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num 5 :value-denom 0)"#;
307
1
    let el = Value::String(inner.to_string());
308
1
    let err =
309
1
        parse_balance_element(&el, inner).expect_err("zero denominator must error, not panic");
310
1
    assert!(
311
1
        matches!(err, CommandError::Command(ref m) if m.contains("non-zero")),
312
        "expected non-zero denom error, got: {err:?}"
313
    );
314
1
}
315

            
316
#[test]
317
1
fn render_balance_list_empty_prints_no_transaction_yet() {
318
1
    let result =
319
1
        render_balance_list(&Value::String("()".to_string())).expect("empty balance render");
320
1
    assert!(matches!(result, Rendered::Lines(ref v) if v == &[NO_TRANSACTION_YET]));
321
1
}
322

            
323
#[test]
324
1
fn balance_zero_single_currency_renders_sentinel() {
325
1
    let inner =
326
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num 0 :value-denom 1)"#;
327
1
    let el = Value::String(inner.to_string());
328
1
    let (r, _, _) = parse_balance_element(&el, inner).expect("parse zero balance");
329
1
    assert_eq!(r, Rational64::new(0, 1));
330
1
    assert_eq!(NO_TRANSACTION_YET, "0 NONE (No transaction yet)");
331
1
}
332

            
333
#[test]
334
1
fn format_rational_whole_number() {
335
1
    assert_eq!(format_rational(Rational64::new(42, 1)), "42");
336
1
    assert_eq!(format_rational(Rational64::new(-7, 1)), "-7");
337
1
}
338

            
339
#[test]
340
1
fn format_rational_fraction() {
341
1
    assert_eq!(format_rational(Rational64::new(3, 4)), "3/4");
342
1
}
343

            
344
#[test]
345
1
fn select_renderer_kitty_hint_falls_back_to_text_when_not_tty() {
346
    // In CI / redirected output stdout is not a tty, so even an explicit
347
    // kitty hint must produce Rendered::Lines rather than binary APC bytes.
348
1
    if std::io::stdout().is_terminal() {
349
        return; // skip when running in an actual terminal
350
1
    }
351
    use plotting::{adapters::BalanceChartOpts, adapters::balance_chart};
352
1
    let rows: Vec<server::command::report::view::ReportRowView> = vec![];
353
1
    let spec = balance_chart(
354
1
        &rows,
355
1
        BalanceChartOpts {
356
1
            kind: plotting::ChartKind::Bar,
357
1
            top_n: 10,
358
1
            sort_order: plotting::adapters::SortOrder::MagnitudeDesc,
359
1
        },
360
    );
361
1
    let result = select_renderer(&spec, "kitty");
362
1
    assert!(
363
1
        matches!(result, Rendered::Lines(_)),
364
        "kitty hint on non-tty must yield text, not binary"
365
    );
366
1
}
367

            
368
#[tokio::test]
369
#[ignore = "perf measurement — run manually: cargo test -- --ignored cold_eval_latency"]
370
async fn cold_eval_latency() {
371
    if std::env::var("DATABASE_URL").is_err() {
372
        return;
373
    }
374
    use rpc::{ScriptCtx, Session};
375
    let start = std::time::Instant::now();
376
    let user_id = Uuid::new_v4();
377
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
378
    let _ = session.handle_form("(:id 1 :form (get-version))").await;
379
    let elapsed = start.elapsed();
380
    println!("cold-eval latency: {elapsed:?}");
381
}