1
mod transaction;
2

            
3
use super::*;
4
use crate::form::Form;
5
use crate::modal::Modal;
6
use crate::route::{Route, RouteCtx};
7
use crate::tabs::config::ConfigCell;
8
use crate::tabs::fetch::Fetch;
9
use crate::tabs::nms_eval::ConsoleEval;
10
use crate::tabs::reports::ReportKind;
11
use crate::view::{Tab, ViewId};
12
use crate::widgets::{EditMode, SelectOption, Widget};
13
use cli_core::render::ListRow;
14
use sqlx::types::Uuid;
15

            
16
33
fn make() -> App {
17
33
    App::new(Uuid::new_v4(), EditMode::Emacs)
18
33
}
19

            
20
3
fn none_option() -> SelectOption {
21
3
    SelectOption {
22
3
        id: String::new(),
23
3
        label: "(none)".to_string(),
24
3
    }
25
3
}
26

            
27
/// One-account list-accounts reply wire for the parent-Select fetch.
28
4
fn accounts_wire(id: u64, uuid: &str, name: &str) -> String {
29
4
    format!(r#"(:id {id} :value "((:account :id \"{uuid}\" :name \"{name}\" :parent \"\"))")"#)
30
4
}
31

            
32
/// Inspect the open account-create form's parent Select.
33
3
fn account_form_select(app: &App) -> Option<&crate::widgets::SelectWidget> {
34
3
    let Some(Modal::Form(form)) = app.overlays.top() else {
35
        return None;
36
    };
37
6
    form.fields.iter().find_map(|f| match &f.widget {
38
3
        Widget::Select(sw) => Some(sw),
39
3
        _ => None,
40
6
    })
41
3
}
42

            
43
#[test]
44
1
fn all_has_six_tabs_ending_in_console() {
45
1
    assert_eq!(Tab::ALL.len(), 6);
46
1
    assert_eq!(Tab::ALL[Tab::ALL.len() - 1], Tab::Console);
47
1
}
48

            
49
#[test]
50
1
fn console_label_is_console() {
51
1
    assert_eq!(Tab::Console.label(), "Console");
52
1
}
53

            
54
#[test]
55
1
fn next_tab_wraps_around() {
56
1
    let mut app = make();
57
1
    app.active_tab = Tab::Console;
58
1
    app.next_tab();
59
1
    assert_eq!(app.active_tab, Tab::Accounts);
60
1
}
61

            
62
#[test]
63
1
fn previous_tab_wraps_around() {
64
1
    let mut app = make();
65
1
    app.active_tab = Tab::Accounts;
66
1
    app.previous_tab();
67
1
    assert_eq!(app.active_tab, Tab::Console);
68
1
}
69

            
70
#[test]
71
1
fn next_tab_advances_in_order() {
72
1
    let mut app = make();
73
1
    app.active_tab = Tab::Accounts;
74
1
    app.next_tab();
75
1
    assert_eq!(app.active_tab, Tab::Transactions);
76
1
    app.next_tab();
77
1
    assert_eq!(app.active_tab, Tab::Commodities);
78
1
}
79

            
80
#[test]
81
1
fn switch_tab_sets_target() {
82
1
    let mut app = make();
83
1
    app.switch_tab(Tab::Reports);
84
1
    assert_eq!(app.active_tab, Tab::Reports);
85
1
}
86

            
87
#[test]
88
1
fn open_and_close_command_line() {
89
1
    let mut app = make();
90
1
    assert!(!app.cmdline.active);
91
1
    app.open_command_line();
92
1
    assert!(app.cmdline.active);
93
1
    app.close_command_line();
94
1
    assert!(!app.cmdline.active);
95
1
}
96

            
97
#[test]
98
1
fn request_quit_sets_flag() {
99
1
    let mut app = make();
100
1
    assert!(!app.should_quit);
101
1
    app.request_quit();
102
1
    assert!(app.should_quit);
103
1
}
104

            
105
#[test]
106
1
fn set_edit_mode_propagates_to_command_line() {
107
1
    let mut app = make();
108
1
    app.open_command_line();
109
1
    app.cmdline.editor.insert_char('x');
110
1
    app.set_edit_mode(EditMode::Vim);
111
1
    assert_eq!(app.cmdline.editor.mode(), EditMode::Vim);
112
1
}
113

            
114
#[tokio::test]
115
1
async fn submit_then_drain_routes_echo_into_scrollback() {
116
1
    let mut app = make();
117
1
    app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
118
1
    app.submit_console_form("(x)".to_string());
119
    // Yield so the echo worker runs and answers before draining.
120
1
    tokio::task::yield_now().await;
121
1
    app.drain_console();
122
1
    assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
123
    // The echo worker reflects the request frame "(:id 1 :form (x))".
124
    // parse_response rejects it (no :value/:error) → Unroutable → pushed
125
    // verbatim as an "[error] …" line.
126
1
    assert!(
127
1
        app.console
128
1
            .scrollback
129
1
            .iter()
130
2
            .any(|l| l.contains("(:id 1 :form (x))"))
131
1
    );
132
1
}
133

            
134
#[test]
135
1
fn submit_without_eval_pushes_not_connected_notice() {
136
1
    let mut app = make();
137
1
    app.submit_console_form("(x)".to_string());
138
1
    assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
139
1
    assert!(
140
1
        app.console
141
1
            .scrollback
142
1
            .iter()
143
2
            .any(|l| l.contains("console not connected"))
144
    );
145
1
}
146

            
147
#[test]
148
1
fn drain_without_eval_is_noop() {
149
1
    let mut app = make();
150
1
    app.drain_console();
151
1
    assert!(app.console.scrollback.is_empty());
152
1
}
153

            
154
#[tokio::test]
155
1
async fn submit_after_worker_stops_surfaces_notice() {
156
1
    let mut app = make();
157
1
    let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
158
1
    let worker = eval.worker_handle();
159
1
    app.attach_console(eval);
160
1
    worker.abort();
161
1
    for _ in 0..200 {
162
2
        if worker.is_finished() {
163
1
            break;
164
1
        }
165
1
        tokio::task::yield_now().await;
166
    }
167
1
    app.submit_console_form("(x)".to_string());
168
1
    assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
169
1
    assert!(
170
1
        app.console
171
1
            .scrollback
172
1
            .iter()
173
2
            .any(|l| l == "eval worker stopped")
174
1
    );
175
1
}
176

            
177
#[tokio::test]
178
1
async fn drain_eval_routes_console_reply_to_scrollback() {
179
1
    let mut app = make();
180
1
    app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
181
1
    app.submit_console_form("42".to_string());
182
1
    tokio::task::yield_now().await;
183
1
    app.drain_eval();
184
    // The echo worker reflects the request frame "(:id 1 :form 42)", which
185
    // parse_response rejects → Unroutable → pushed to scrollback as an
186
    // "[error] …" line, so scrollback is non-empty.
187
1
    assert!(!app.console.scrollback.is_empty());
188
1
}
189

            
190
#[tokio::test]
191
1
async fn drain_eval_notice_goes_to_scrollback() {
192
1
    let mut app = make();
193
1
    let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
194
1
    let worker = eval.worker_handle();
195
1
    app.attach_console(eval);
196
1
    worker.abort();
197
1
    for _ in 0..200 {
198
2
        if worker.is_finished() {
199
1
            break;
200
1
        }
201
1
        tokio::task::yield_now().await;
202
    }
203
1
    app.drain_eval();
204
1
    assert!(
205
1
        app.console
206
1
            .scrollback
207
1
            .iter()
208
1
            .any(|l| l == "eval worker stopped")
209
1
    );
210
1
}
211

            
212
#[test]
213
1
fn switching_away_from_console_clears_focus() {
214
1
    let mut app = make();
215
1
    app.active_tab = Tab::Console;
216
1
    app.console_focused = true;
217
1
    app.switch_tab(Tab::Accounts);
218
1
    assert!(
219
1
        !app.console_focused,
220
        "leaving console must drop its input focus"
221
    );
222
1
    app.active_tab = Tab::Console;
223
1
    app.console_focused = true;
224
1
    app.next_tab();
225
1
    assert!(!app.console_focused, "next_tab off console must drop focus");
226
1
}
227

            
228
#[test]
229
1
fn refresh_tab_resets_to_idle() {
230
1
    let mut app = make();
231
1
    app.accounts.state = Fetch::Loaded(vec![ListRow {
232
1
        id: None,
233
1
        cells: vec!["foo".into()],
234
1
    }]);
235
1
    app.accounts.selected = 2;
236
1
    app.refresh_tab(Tab::Accounts);
237
1
    assert_eq!(app.accounts.state, Fetch::Idle);
238
1
    assert_eq!(app.accounts.selected, 0);
239
1
}
240

            
241
#[test]
242
1
fn switch_tab_to_reports_does_not_trigger_fetch() {
243
1
    let app = make();
244
1
    assert!(matches!(app.accounts.state, Fetch::Idle));
245
1
    assert!(matches!(app.transactions.state, Fetch::Idle));
246
1
    assert!(matches!(app.commodities.state, Fetch::Idle));
247
1
}
248

            
249
#[test]
250
1
fn refresh_tab_while_loading_is_noop() {
251
1
    let mut app = make();
252
1
    app.accounts.state = Fetch::Loading { id: 7 };
253
1
    app.refresh_tab(Tab::Accounts);
254
    // A fetch is in flight — refresh must not reset it or submit again.
255
1
    assert!(matches!(app.accounts.state, Fetch::Loading { id: 7 }));
256
1
}
257

            
258
#[tokio::test]
259
1
async fn worker_stop_fails_inflight_tabs_and_clears_routes() {
260
1
    let mut app = make();
261
1
    let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
262
1
    let worker = eval.worker_handle();
263
1
    app.attach_console(eval);
264
    // A list fetch is in flight when the worker dies.
265
1
    app.accounts.state = Fetch::Loading { id: 5 };
266
1
    app.pending_routes.insert(
267
        5,
268
1
        Route {
269
1
            target: ViewId::Accounts,
270
1
            ctx: RouteCtx::None,
271
1
        },
272
    );
273
1
    worker.abort();
274
1
    for _ in 0..200 {
275
2
        if worker.is_finished() {
276
1
            break;
277
1
        }
278
1
        tokio::task::yield_now().await;
279
    }
280
1
    app.drain_eval();
281
1
    assert!(
282
1
        matches!(app.accounts.state, Fetch::Error(_)),
283
        "stuck-loading tab must fail: {:?}",
284
        app.accounts.state
285
    );
286
1
    assert!(
287
1
        app.pending_routes.is_empty(),
288
1
        "routes must be cleared on worker stop"
289
1
    );
290
1
}
291

            
292
6
fn wire_error(id: u64, code: &str, msg: &str) -> String {
293
6
    format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
294
6
}
295

            
296
4
fn wire_value(id: u64, val: &str) -> String {
297
4
    format!("(:id {id} :value {val})")
298
4
}
299

            
300
#[test]
301
1
fn deliver_reply_accounts_updates_accounts_state() {
302
1
    let mut app = make();
303
1
    let wire = wire_error(1, "db", "connection failed");
304
1
    app.deliver_reply(
305
1
        Route {
306
1
            target: ViewId::Accounts,
307
1
            ctx: RouteCtx::None,
308
1
        },
309
1
        &wire,
310
    );
311
1
    assert!(
312
1
        matches!(app.accounts.state, Fetch::Error(ref s) if s.contains("db")),
313
        "accounts state must reflect the error reply"
314
    );
315
1
}
316

            
317
#[test]
318
1
fn deliver_reply_transactions_updates_transactions_state() {
319
1
    let mut app = make();
320
1
    let wire = wire_error(1, "tx", "failed");
321
1
    app.deliver_reply(
322
1
        Route {
323
1
            target: ViewId::Transactions,
324
1
            ctx: RouteCtx::None,
325
1
        },
326
1
        &wire,
327
    );
328
1
    assert!(matches!(app.transactions.state, Fetch::Error(_)));
329
1
}
330

            
331
#[test]
332
1
fn deliver_reply_commodities_updates_commodities_state() {
333
1
    let mut app = make();
334
1
    let wire = wire_error(1, "c", "failed");
335
1
    app.deliver_reply(
336
1
        Route {
337
1
            target: ViewId::Commodities,
338
1
            ctx: RouteCtx::None,
339
1
        },
340
1
        &wire,
341
    );
342
1
    assert!(matches!(app.commodities.state, Fetch::Error(_)));
343
1
}
344

            
345
#[test]
346
1
fn deliver_reply_config_updates_config_cell() {
347
1
    let mut app = make();
348
1
    let wire = wire_error(1, "cfg", "not found");
349
1
    app.deliver_reply(
350
1
        Route {
351
1
            target: ViewId::Config,
352
1
            ctx: RouteCtx::Config {
353
1
                key: "locale".to_string(),
354
1
            },
355
1
        },
356
1
        &wire,
357
    );
358
1
    let cell = app
359
1
        .config
360
1
        .entries
361
1
        .iter()
362
1
        .find(|(k, _)| k == "locale")
363
1
        .map(|(_, c)| c);
364
1
    assert!(
365
1
        matches!(cell, Some(ConfigCell::Error(_))),
366
        "config entry must reflect the error reply"
367
    );
368
1
}
369

            
370
#[test]
371
1
fn deliver_reply_reports_updates_reports_state() {
372
1
    let mut app = make();
373
1
    let wire = wire_error(1, "rpt", "failed");
374
1
    app.deliver_reply(
375
1
        Route {
376
1
            target: ViewId::Reports,
377
1
            ctx: RouteCtx::Reports {
378
1
                kind: ReportKind::Balance,
379
1
                chart: "bar".to_string(),
380
1
            },
381
1
        },
382
1
        &wire,
383
    );
384
1
    assert!(matches!(app.reports.state, Fetch::Error(_)));
385
1
}
386

            
387
#[test]
388
1
fn deliver_reply_console_pushes_to_scrollback() {
389
1
    let mut app = make();
390
1
    let wire = wire_value(1, "42");
391
1
    app.deliver_reply(
392
1
        Route {
393
1
            target: ViewId::Console,
394
1
            ctx: RouteCtx::None,
395
1
        },
396
1
        &wire,
397
    );
398
1
    assert!(
399
1
        !app.console.scrollback.is_empty(),
400
        "console reply must appear in scrollback"
401
    );
402
1
}
403

            
404
#[test]
405
1
fn orphan_reply_sets_warn_status_and_pushes_scrollback() {
406
1
    let mut app = make();
407
1
    let wire = wire_value(99, "42");
408
    // No route is present for id=99 → orphan branch fires.
409
1
    app.route_reply(99, &wire);
410
1
    assert!(
411
1
        app.status.contains("[warn] orphan reply id=99"),
412
        "status must carry the orphan id; got {:?}",
413
        app.status
414
    );
415
1
    assert!(
416
1
        !app.console.scrollback.is_empty(),
417
        "orphan reply must be echoed to scrollback"
418
    );
419
1
}
420

            
421
#[test]
422
1
fn deliver_reply_mutation_success_refreshes_accounts() {
423
1
    let mut app = make();
424
1
    app.accounts.state = Fetch::Loaded(vec![ListRow {
425
1
        id: None,
426
1
        cells: vec!["existing".into()],
427
1
    }]);
428
1
    let wire = wire_value(1, "\"550e8400-e29b-41d4-a716-446655440001\"");
429
1
    app.deliver_reply(
430
1
        Route {
431
1
            target: ViewId::Accounts,
432
1
            ctx: RouteCtx::Mutation {
433
1
                refresh: ViewId::Accounts,
434
1
            },
435
1
        },
436
1
        &wire,
437
    );
438
1
    assert!(
439
1
        matches!(app.accounts.state, Fetch::Idle),
440
        "accounts must be reset to Idle after successful mutation"
441
    );
442
1
}
443

            
444
#[test]
445
1
fn deliver_reply_mutation_error_sets_status() {
446
1
    let mut app = make();
447
1
    let wire = wire_error(1, "constraint", "account exists");
448
1
    app.deliver_reply(
449
1
        Route {
450
1
            target: ViewId::Accounts,
451
1
            ctx: RouteCtx::Mutation {
452
1
                refresh: ViewId::Accounts,
453
1
            },
454
1
        },
455
1
        &wire,
456
    );
457
1
    assert!(
458
1
        app.status.contains("mutation failed"),
459
        "status must reflect mutation error, got: {}",
460
        app.status
461
    );
462
1
    assert!(matches!(app.accounts.state, Fetch::Idle));
463
1
}
464

            
465
#[test]
466
1
fn deliver_reply_mutation_success_refreshes_commodities() {
467
1
    let mut app = make();
468
1
    app.commodities.state = Fetch::Loaded(vec![ListRow {
469
1
        id: None,
470
1
        cells: vec!["USD".into()],
471
1
    }]);
472
1
    let wire = wire_value(1, "\"550e8400-e29b-41d4-a716-446655440001\"");
473
1
    app.deliver_reply(
474
1
        Route {
475
1
            target: ViewId::Commodities,
476
1
            ctx: RouteCtx::Mutation {
477
1
                refresh: ViewId::Commodities,
478
1
            },
479
1
        },
480
1
        &wire,
481
    );
482
1
    assert!(
483
1
        matches!(app.commodities.state, Fetch::Idle),
484
        "commodities must be reset to Idle after successful mutation"
485
    );
486
1
}
487

            
488
#[test]
489
1
fn deliver_reply_form_options_stale_seq_is_dropped() {
490
1
    let mut app = make();
491
1
    app.overlays.push(Modal::Form(Form::account_create(
492
1
        EditMode::Emacs,
493
1
        vec![none_option()],
494
1
    )));
495
1
    app.form_options_seq = 2;
496
1
    let wire = accounts_wire(9, "550e8400-e29b-41d4-a716-446655440001", "Root");
497
1
    app.deliver_reply(
498
1
        Route {
499
1
            target: ViewId::Accounts,
500
1
            ctx: RouteCtx::FormOptions {
501
1
                seq: 1,
502
1
                source: crate::route::FormOptionsSource::Accounts,
503
1
            },
504
1
        },
505
1
        &wire,
506
    );
507
1
    assert_eq!(
508
1
        account_form_select(&app).map(crate::widgets::SelectWidget::option_count),
509
        Some(1),
510
        "a stale-seq reply must not populate the parent Select"
511
    );
512
1
}
513

            
514
#[test]
515
1
fn deliver_reply_form_options_matching_seq_populates() {
516
1
    let mut app = make();
517
1
    app.overlays.push(Modal::Form(Form::account_create(
518
1
        EditMode::Emacs,
519
1
        vec![none_option()],
520
1
    )));
521
1
    app.form_options_seq = 1;
522
1
    let uuid = "550e8400-e29b-41d4-a716-446655440001";
523
1
    let wire = accounts_wire(9, uuid, "Root");
524
1
    app.deliver_reply(
525
1
        Route {
526
1
            target: ViewId::Accounts,
527
1
            ctx: RouteCtx::FormOptions {
528
1
                seq: 1,
529
1
                source: crate::route::FormOptionsSource::Accounts,
530
1
            },
531
1
        },
532
1
        &wire,
533
    );
534
1
    let sw = account_form_select(&app).expect("account form select present");
535
1
    assert_eq!(sw.option_count(), 2, "(none) + the one account");
536
1
}
537

            
538
#[test]
539
1
fn deliver_reply_form_options_second_reply_does_not_clobber_selection() {
540
1
    let mut app = make();
541
1
    let root = "550e8400-e29b-41d4-a716-446655440001";
542
1
    let opts = vec![
543
1
        none_option(),
544
1
        SelectOption {
545
1
            id: root.to_string(),
546
1
            label: "Root".to_string(),
547
1
        },
548
    ];
549
1
    app.overlays
550
1
        .push(Modal::Form(Form::account_create(EditMode::Emacs, opts)));
551
1
    app.form_options_seq = 1;
552
    // User selects "Root".
553
1
    if let Some(Modal::Form(form)) = app.overlays.top_mut()
554
1
        && let Widget::Select(ref mut sw) = form.fields[1].widget
555
1
    {
556
1
        sw.next();
557
1
    }
558
    // A second reply for the same form arrives with a different account.
559
1
    let wire = accounts_wire(9, "550e8400-e29b-41d4-a716-446655440099", "Other");
560
1
    app.deliver_reply(
561
1
        Route {
562
1
            target: ViewId::Accounts,
563
1
            ctx: RouteCtx::FormOptions {
564
1
                seq: 1,
565
1
                source: crate::route::FormOptionsSource::Accounts,
566
1
            },
567
1
        },
568
1
        &wire,
569
    );
570
1
    let sw = account_form_select(&app).expect("account form select present");
571
1
    assert_eq!(sw.option_count(), 2, "populated Select must not be reset");
572
1
    assert_eq!(sw.value(), root, "user's parent selection must survive");
573
1
}