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

            
3
mod builder;
4
mod eval_form_tests;
5
mod mutation_forms;
6

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

            
12
use super::*;
13

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            
103
4
fn bool_success_lines(wire: &str) -> Vec<String> {
104
4
    let WireValue::Value(value) = parse_wire(wire).expect("value frame");
105
4
    match render_bool_success(&value) {
106
4
        Ok(Rendered::Lines(lines)) => lines,
107
        _ => panic!("{wire} must render as lines"),
108
    }
109
4
}
110

            
111
#[test]
112
1
fn bool_success_renders_zero_flag_as_false() {
113
1
    assert_eq!(bool_success_lines("(:id 1 :value 0)"), vec!["false"]);
114
1
}
115

            
116
#[test]
117
1
fn bool_success_renders_one_flag_as_true() {
118
1
    assert_eq!(bool_success_lines("(:id 1 :value 1)"), vec!["true"]);
119
1
}
120

            
121
#[test]
122
1
fn bool_success_renders_t_as_true_and_nil_as_false() {
123
1
    assert_eq!(bool_success_lines("(:id 1 :value t)"), vec!["true"]);
124
1
    assert_eq!(bool_success_lines("(:id 1 :value nil)"), vec!["false"]);
125
1
}
126

            
127
#[test]
128
1
fn bool_success_rejects_a_reply_that_is_not_a_flag() {
129
1
    let WireValue::Value(value) = parse_wire("(:id 1 :value \"yes\")").expect("value frame");
130
1
    assert!(render_bool_success(&value).is_err());
131
1
}
132

            
133
#[test]
134
1
fn adversarial_name_round_trips_through_escape() {
135
1
    let name = r#"weird"(name)"#;
136
1
    let form = format!(
137
        "(:account :id \"x\" :name {} :parent \"\")",
138
1
        escape_str(name)
139
    );
140
1
    let element = parse_value(&form);
141
1
    assert_eq!(account_list_line(&element), Some(name.to_string()));
142
1
}
143

            
144
#[test]
145
1
fn entity_list_with_two_elements_is_a_proper_list() {
146
1
    let v = parse_value(
147
1
        r#"((:account :id "a" :name "one" :parent "") (:account :id "b" :name "two" :parent ""))"#,
148
    );
149
1
    let elements = list_to_vec(&v).expect("proper list");
150
1
    assert_eq!(elements.len(), 2);
151
1
    let lines: Vec<_> = elements.iter().filter_map(account_list_line).collect();
152
1
    assert_eq!(lines, vec!["one".to_string(), "two".to_string()]);
153
1
    let single = Pair::cons(elements[0].clone(), Value::Nil);
154
1
    assert_eq!(list_to_vec(&single).expect("single").len(), 1);
155
1
}
156

            
157
#[test]
158
1
fn transaction_list_line_uses_note_when_present() {
159
1
    let el = parse_value(
160
1
        r#"(:transaction :id "aaa-bbb" :note "my-note" :post-date "2026-01-15T00:00:00+00:00")"#,
161
    );
162
1
    assert_eq!(
163
1
        transaction_list_line(&el),
164
1
        Some("my-note - 2026-01-15T00:00:00+00:00".to_string())
165
    );
166
1
}
167

            
168
#[test]
169
1
fn transaction_list_line_falls_back_to_id_when_note_empty() {
170
1
    let el = parse_value(
171
1
        r#"(:transaction :id "aaa-bbb" :note "" :post-date "2026-01-15T00:00:00+00:00")"#,
172
    );
173
1
    assert_eq!(
174
1
        transaction_list_line(&el),
175
1
        Some("aaa-bbb - 2026-01-15T00:00:00+00:00".to_string())
176
    );
177
1
}
178

            
179
#[test]
180
1
fn parse_balance_element_single_currency() {
181
1
    let inner =
182
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num -50 :value-denom 1)"#;
183
1
    let el = Value::String(inner.to_string());
184
1
    let (r, symbol, name) = parse_balance_element(&el, inner).unwrap();
185
1
    assert_eq!(r, Rational64::new(-50, 1));
186
1
    assert_eq!(symbol, "USD");
187
1
    assert_eq!(name, "US Dollar");
188
1
}
189

            
190
#[test]
191
1
fn build_create_transaction_form_single_currency_no_prices() {
192
1
    let from = Uuid::new_v4();
193
1
    let to = Uuid::new_v4();
194
1
    let currency = Uuid::new_v4();
195
1
    let value = Rational64::new(100, 1);
196
1
    let form = build_create_transaction_form(from, to, currency, currency, value, None, None)
197
1
        .expect("same-currency build");
198
1
    assert!(form.starts_with("(create-transaction "));
199
1
    assert!(form.contains(":splits"));
200
1
    assert!(
201
1
        !form.contains(":prices"),
202
        "same currency must not emit :prices"
203
    );
204
1
    assert!(form.contains(":value -100"), "from-split must be negative");
205
1
    assert!(form.contains(":value 100"), "to-split must be positive");
206
1
    assert!(form.contains(":enter-date"), "must emit :enter-date");
207
3
    let ts = |key: &str| {
208
3
        let after = form.split(&format!("{key} \\\"")).nth(1)?;
209
3
        after.split("\\\"").next().map(str::to_string)
210
3
    };
211
1
    assert_eq!(
212
1
        ts(":post-date"),
213
1
        ts(":enter-date"),
214
        "enter_date must equal post_date"
215
    );
216
1
    assert!(ts(":post-date").is_some());
217
1
}
218

            
219
#[test]
220
1
fn build_create_transaction_form_cross_currency_emits_prices() {
221
1
    let from = Uuid::new_v4();
222
1
    let to = Uuid::new_v4();
223
1
    let usd = Uuid::new_v4();
224
1
    let jpy = Uuid::new_v4();
225
1
    let value = Rational64::new(100, 1);
226
1
    let to_amount = Rational64::new(15000, 1);
227
1
    let form =
228
1
        build_create_transaction_form(from, to, usd, jpy, value, Some(to_amount), Some("lunch"))
229
1
            .expect("cross-currency build");
230
1
    assert!(form.contains(":prices"), "cross-currency must emit :prices");
231
1
    assert!(
232
1
        form.contains(":note \\\"lunch\\\""),
233
        "note must be embedded (escaped), got: {form}"
234
    );
235
    // 100/15000 reduces to the canonical 1/150 (same FX ratio, gcd-divided).
236
1
    assert!(
237
1
        form.contains(":value-num 1 :value-denom 150"),
238
        "reduced FX rate must be 1/150, got: {form}"
239
    );
240
1
}
241

            
242
#[test]
243
1
fn build_create_transaction_form_cross_currency_without_to_amount_errors() {
244
1
    let from = Uuid::new_v4();
245
1
    let to = Uuid::new_v4();
246
1
    let usd = Uuid::new_v4();
247
1
    let jpy = Uuid::new_v4();
248
1
    let value = Rational64::new(100, 1);
249
1
    let err = build_create_transaction_form(from, to, usd, jpy, value, None, None)
250
1
        .expect_err("cross-currency without to_amount must error");
251
1
    assert!(
252
1
        matches!(err, CommandError::Argument(ref m) if m.contains("to_amount")),
253
        "expected to_amount argument error, got: {err:?}"
254
    );
255
1
}
256

            
257
#[test]
258
1
fn ssh_key_list_line_all_fields() {
259
1
    let el = parse_value(
260
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")"#,
261
    );
262
1
    let line = ssh_key_list_line(&el).expect("should render");
263
1
    assert!(line.contains("laptop"), "label should be annotation");
264
1
    assert!(line.contains("SHA256:abc"), "fingerprint must appear");
265
1
    assert!(line.contains("ssh-ed25519"), "key type must appear");
266
1
    assert!(
267
1
        line.contains("2026-05-01T12:00:00+00:00"),
268
        "created-at must appear"
269
    );
270
1
    assert!(
271
1
        line.contains("2026-06-01T08:00:00+00:00"),
272
        "last-used-at must appear"
273
    );
274
1
}
275

            
276
#[test]
277
1
fn ssh_key_list_line_empty_name_falls_back_to_fingerprint() {
278
1
    let el = parse_value(
279
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 "")"#,
280
    );
281
1
    let line = ssh_key_list_line(&el).expect("should render");
282
1
    assert!(
283
1
        line.starts_with("SHA256:xyz"),
284
        "fingerprint should be label when name empty, got: {line}"
285
    );
286
1
    assert!(
287
1
        line.contains("never"),
288
        "empty last-used-at should render as 'never'"
289
    );
290
1
}
291

            
292
#[test]
293
1
fn ssh_key_list_line_missing_fingerprint_returns_none() {
294
1
    let el = parse_value(r#"(:account :id "x" :name "y" :parent "")"#);
295
1
    assert_eq!(
296
1
        ssh_key_list_line(&el),
297
        None,
298
        "missing :fingerprint must return None"
299
    );
300
1
}
301

            
302
#[test]
303
1
fn ssh_key_list_line_missing_required_field_returns_none() {
304
    // :key-type and :created-at are contract-required — their absence signals a
305
    // wire-shape regression and must surface as None, not an empty-bracket line.
306
1
    let no_type = parse_value(
307
1
        r#"(:ssh-key :fingerprint "SHA256:abc" :created-at "2026-01-01T00:00:00+00:00")"#,
308
    );
309
1
    assert_eq!(
310
1
        ssh_key_list_line(&no_type),
311
        None,
312
        "missing :key-type → None"
313
    );
314
1
    let no_created = parse_value(r#"(:ssh-key :fingerprint "SHA256:abc" :key-type "ssh-rsa")"#);
315
1
    assert_eq!(
316
1
        ssh_key_list_line(&no_created),
317
        None,
318
        "missing :created-at → None"
319
    );
320
1
}
321

            
322
#[test]
323
1
fn parse_balance_element_rejects_zero_denominator() {
324
1
    let inner =
325
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num 5 :value-denom 0)"#;
326
1
    let el = Value::String(inner.to_string());
327
1
    let err =
328
1
        parse_balance_element(&el, inner).expect_err("zero denominator must error, not panic");
329
1
    assert!(
330
1
        matches!(err, CommandError::Command(ref m) if m.contains("non-zero")),
331
        "expected non-zero denom error, got: {err:?}"
332
    );
333
1
}
334

            
335
#[test]
336
1
fn render_balance_list_empty_prints_no_transaction_yet() {
337
1
    let result =
338
1
        render_balance_list(&Value::String("()".to_string())).expect("empty balance render");
339
1
    assert!(matches!(result, Rendered::Lines(ref v) if v == &[NO_TRANSACTION_YET]));
340
1
}
341

            
342
#[test]
343
1
fn balance_zero_single_currency_renders_sentinel() {
344
1
    let inner =
345
1
        r#"(:commodity-id "abc" :symbol "USD" :name "US Dollar" :value-num 0 :value-denom 1)"#;
346
1
    let el = Value::String(inner.to_string());
347
1
    let (r, _, _) = parse_balance_element(&el, inner).expect("parse zero balance");
348
1
    assert_eq!(r, Rational64::new(0, 1));
349
1
    assert_eq!(NO_TRANSACTION_YET, "0 NONE (No transaction yet)");
350
1
}
351

            
352
#[test]
353
1
fn format_rational_whole_number() {
354
1
    assert_eq!(format_rational(Rational64::new(42, 1)), "42");
355
1
    assert_eq!(format_rational(Rational64::new(-7, 1)), "-7");
356
1
}
357

            
358
#[test]
359
1
fn format_rational_fraction() {
360
1
    assert_eq!(format_rational(Rational64::new(3, 4)), "3/4");
361
1
}
362

            
363
#[test]
364
1
fn select_renderer_kitty_hint_falls_back_to_text_when_not_tty() {
365
    // In CI / redirected output stdout is not a tty, so even an explicit
366
    // kitty hint must produce Rendered::Lines rather than binary APC bytes.
367
1
    if std::io::stdout().is_terminal() {
368
        return; // skip when running in an actual terminal
369
1
    }
370
    use plotting::{adapters::BalanceChartOpts, adapters::balance_chart};
371
1
    let rows: Vec<server::command::report::view::ReportRowView> = vec![];
372
1
    let spec = balance_chart(
373
1
        &rows,
374
1
        BalanceChartOpts {
375
1
            kind: plotting::ChartKind::Bar,
376
1
            top_n: 10,
377
1
            sort_order: plotting::adapters::SortOrder::MagnitudeDesc,
378
1
        },
379
    );
380
1
    let result = select_renderer(&spec, "kitty");
381
1
    assert!(
382
1
        matches!(result, Rendered::Lines(_)),
383
        "kitty hint on non-tty must yield text, not binary"
384
    );
385
1
}
386

            
387
#[tokio::test]
388
#[ignore = "perf measurement — run manually: cargo test -- --ignored cold_eval_latency"]
389
async fn cold_eval_latency() {
390
    if std::env::var("DATABASE_URL").is_err() {
391
        return;
392
    }
393
    use rpc::{ScriptCtx, Session};
394
    let start = std::time::Instant::now();
395
    let user_id = Uuid::new_v4();
396
    let mut session = Session::new(ScriptCtx::new(user_id)).expect("Session::new");
397
    let _ = session.handle_form("(:id 1 :form (get-version))").await;
398
    let elapsed = start.elapsed();
399
    println!("cold-eval latency: {elapsed:?}");
400
}
401

            
402
#[test]
403
1
fn render_entity_list_nil_renders_no_rows() {
404
1
    let result = render_entity_list(Value::Nil, account_list_line).expect("nil entity list");
405
1
    assert!(matches!(result, Rendered::Lines(ref v) if v.is_empty()));
406
1
}
407

            
408
#[test]
409
1
fn render_transaction_list_nil_renders_no_rows() {
410
1
    let result = render_transaction_list(&Value::Nil).expect("nil transaction list");
411
1
    assert!(matches!(result, Rendered::Lines(ref v) if v.is_empty()));
412
1
}
413

            
414
#[test]
415
1
fn render_balance_list_nil_prints_no_transaction_yet() {
416
1
    let result = render_balance_list(&Value::Nil).expect("nil balance list");
417
1
    assert!(matches!(result, Rendered::Lines(ref v) if v == &[NO_TRANSACTION_YET]));
418
1
}