1
//! Tests for key-event routing.
2

            
3
mod completion;
4
mod confirm;
5
mod select;
6
mod transaction;
7

            
8
use super::*;
9
use sqlx::types::Uuid;
10

            
11
51
fn make_app() -> App {
12
51
    App::new(Uuid::new_v4(), EditMode::Emacs)
13
51
}
14

            
15
#[test]
16
1
fn quit_intent_sets_quit_flag() {
17
1
    let mut app = make_app();
18
1
    apply(&mut app, Intent::Quit);
19
1
    assert!(app.should_quit);
20
1
}
21

            
22
#[test]
23
1
fn next_tab_intent_advances_tab() {
24
1
    let mut app = make_app();
25
1
    app.active_tab = Tab::Accounts;
26
1
    apply(&mut app, Intent::NextTab);
27
1
    assert_eq!(app.active_tab, Tab::Transactions);
28
1
}
29

            
30
#[test]
31
1
fn select_tab_intent_jumps_directly() {
32
1
    let mut app = make_app();
33
1
    apply(&mut app, Intent::SelectTab(Tab::Reports));
34
1
    assert_eq!(app.active_tab, Tab::Reports);
35
1
}
36

            
37
#[test]
38
1
fn open_command_line_activates_and_accepts_input() {
39
1
    let mut app = make_app();
40
1
    apply(&mut app, Intent::OpenCommandLine);
41
1
    assert!(app.cmdline.active);
42
1
    apply(&mut app, Intent::InsertChar('v'));
43
1
    apply(&mut app, Intent::InsertChar('x'));
44
1
    assert_eq!(app.cmdline.editor.buffer(), "vx");
45
1
}
46

            
47
#[test]
48
1
fn command_line_escape_exits_when_already_in_normal_mode() {
49
1
    let mut app = make_app();
50
1
    app.set_edit_mode(EditMode::Vim);
51
1
    apply(&mut app, Intent::OpenCommandLine);
52
1
    app.cmdline.editor.enter_normal_mode();
53
1
    apply(&mut app, Intent::CloseTopmost);
54
1
    assert!(!app.cmdline.active);
55
1
}
56

            
57
#[test]
58
1
fn command_line_escape_first_drops_vim_to_normal() {
59
1
    let mut app = make_app();
60
1
    app.set_edit_mode(EditMode::Vim);
61
1
    apply(&mut app, Intent::OpenCommandLine);
62
1
    apply(&mut app, Intent::InsertChar('x'));
63
1
    assert_eq!(app.cmdline.editor.vim_mode(), VimMode::Insert);
64
1
    apply(&mut app, Intent::CloseTopmost);
65
1
    assert!(app.cmdline.active, "first Esc should keep cmdline open");
66
1
    assert_eq!(app.cmdline.editor.vim_mode(), VimMode::Normal);
67
1
}
68

            
69
#[test]
70
1
fn submit_command_line_records_buffer_and_closes() {
71
1
    let mut app = make_app();
72
1
    apply(&mut app, Intent::OpenCommandLine);
73
7
    for c in "version".chars() {
74
7
        apply(&mut app, Intent::InsertChar(c));
75
7
    }
76
1
    apply(&mut app, Intent::SubmitCommandLine);
77
1
    assert!(!app.cmdline.active, "command line must close after submit");
78
    // version is an eval leaf — it submits the form and switches to Console.
79
1
    assert_eq!(app.active_tab, Tab::Console);
80
1
    assert!(
81
1
        app.console
82
1
            .scrollback
83
1
            .iter()
84
1
            .any(|l| l.contains("(get-version)")),
85
        "form should appear in console scrollback"
86
    );
87
1
}
88

            
89
#[test]
90
1
fn submit_command_line_with_unknown_path_surfaces_error() {
91
1
    let mut app = make_app();
92
1
    apply(&mut app, Intent::OpenCommandLine);
93
9
    for c in "bogus-cmd".chars() {
94
9
        apply(&mut app, Intent::InsertChar(c));
95
9
    }
96
1
    apply(&mut app, Intent::SubmitCommandLine);
97
1
    assert!(app.status.contains("unknown"));
98
1
}
99

            
100
#[test]
101
1
fn report_params_modal_accepts_typing_and_focus_cycle() {
102
    // `reports activity` with no dates opens the params modal; the user must be
103
    // able to type into it, Tab to cycle focus, and have the buffers captured.
104
1
    let mut app = make_app();
105
1
    apply(&mut app, Intent::OpenCommandLine);
106
16
    for c in "reports activity".chars() {
107
16
        apply(&mut app, Intent::InsertChar(c));
108
16
    }
109
1
    apply(&mut app, Intent::SubmitCommandLine);
110
1
    assert!(
111
1
        matches!(app.overlays.top(), Some(Modal::Form(_))),
112
        "missing dates should open the report-params modal"
113
    );
114
10
    for c in "2024-01-01".chars() {
115
10
        apply(&mut app, Intent::InsertChar(c));
116
10
    }
117
1
    apply(&mut app, Intent::NextTab); // From → To
118
10
    for c in "2024-12-31".chars() {
119
10
        apply(&mut app, Intent::InsertChar(c));
120
10
    }
121
1
    match app.overlays.top() {
122
1
        Some(Modal::Form(f)) => {
123
1
            assert_eq!(f.fields[0].widget.value(), "2024-01-01");
124
1
            assert_eq!(f.fields[1].widget.value(), "2024-12-31");
125
        }
126
        other => panic!("expected Form modal, got {other:?}"),
127
    }
128
1
}
129

            
130
#[test]
131
1
fn submit_config_set_opens_form_modal() {
132
1
    let mut app = make_app();
133
1
    apply(&mut app, Intent::OpenCommandLine);
134
31
    for c in "config set name=locale value=en".chars() {
135
31
        apply(&mut app, Intent::InsertChar(c));
136
31
    }
137
1
    apply(&mut app, Intent::SubmitCommandLine);
138
1
    assert!(!app.overlays.is_empty());
139
1
    match app.overlays.top() {
140
1
        Some(Modal::Form(f)) => {
141
1
            assert_eq!(f.fields[0].widget.value(), "locale");
142
1
            assert_eq!(f.fields[1].widget.value(), "en");
143
        }
144
        other => panic!("expected Form modal, got {other:?}"),
145
    }
146
1
}
147

            
148
#[test]
149
1
fn open_help_pushes_a_modal() {
150
1
    let mut app = make_app();
151
1
    apply(&mut app, Intent::OpenHelp);
152
1
    assert!(!app.overlays.is_empty());
153
1
    assert!(matches!(app.overlays.top(), Some(Modal::Help)));
154
1
}
155

            
156
#[test]
157
1
fn close_topmost_pops_modal_before_touching_tabs() {
158
1
    let mut app = make_app();
159
1
    apply(&mut app, Intent::OpenHelp);
160
1
    apply(&mut app, Intent::Quit);
161
1
    assert!(
162
1
        !app.should_quit,
163
        "quit should be swallowed by the modal layer"
164
    );
165
1
    apply(&mut app, Intent::CloseTopmost);
166
1
    assert!(app.overlays.is_empty());
167
1
    apply(&mut app, Intent::Quit);
168
1
    assert!(app.should_quit);
169
1
}
170

            
171
#[test]
172
1
fn toggle_edit_mode_flips_emacs_vim() {
173
1
    let mut app = make_app();
174
1
    assert_eq!(app.edit_mode, EditMode::Emacs);
175
1
    apply(&mut app, Intent::ToggleEditMode);
176
1
    assert_eq!(app.edit_mode, EditMode::Vim);
177
1
    apply(&mut app, Intent::ToggleEditMode);
178
1
    assert_eq!(app.edit_mode, EditMode::Emacs);
179
1
}
180

            
181
#[test]
182
1
fn console_focus_sets_flag_and_blur_clears_it() {
183
1
    let mut app = make_app();
184
1
    app.active_tab = Tab::Console;
185
1
    apply(&mut app, Intent::ConsoleFocus);
186
1
    assert!(app.console_focused);
187
1
    apply(&mut app, Intent::ConsoleBlur);
188
1
    assert!(!app.console_focused);
189
1
}
190

            
191
#[test]
192
1
fn console_editing_intents_mutate_input_editor() {
193
1
    let mut app = make_app();
194
1
    app.console_focused = true;
195
1
    apply(&mut app, Intent::InsertChar('('));
196
1
    apply(&mut app, Intent::InsertChar('a'));
197
1
    assert_eq!(app.console.input.buffer(), "(a");
198
1
    apply(&mut app, Intent::DeleteBackward);
199
1
    assert_eq!(app.console.input.buffer(), "(");
200
1
}
201

            
202
#[test]
203
1
fn console_submit_incomplete_form_keeps_buffering() {
204
1
    let mut app = make_app();
205
1
    app.console_focused = true;
206
5
    for c in "(list".chars() {
207
5
        apply(&mut app, Intent::InsertChar(c));
208
5
    }
209
1
    apply(&mut app, Intent::ConsoleSubmit);
210
1
    assert_eq!(app.console.pending, "(list");
211
1
    assert!(app.console.input.buffer().is_empty());
212
1
}
213

            
214
#[tokio::test]
215
1
async fn console_submit_routes_complete_form_to_echo_eval() {
216
    use crate::tabs::nms_eval::ConsoleEval;
217
1
    let mut app = make_app();
218
1
    app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
219
1
    app.console_focused = true;
220
7
    for c in "(+ 1 2)".chars() {
221
7
        apply(&mut app, Intent::InsertChar(c));
222
7
    }
223
1
    apply(&mut app, Intent::ConsoleSubmit);
224
1
    tokio::task::yield_now().await;
225
1
    app.drain_console();
226
1
    assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
227
1
    assert!(
228
1
        app.console
229
1
            .scrollback
230
1
            .iter()
231
2
            .any(|l| l.contains("(:id 1 :form (+ 1 2))"))
232
1
    );
233
1
}
234

            
235
#[test]
236
1
fn console_history_keys_navigate_prior_submissions() {
237
1
    let mut app = make_app();
238
1
    app.console_focused = true;
239
2
    for form in ["(a)", "(b)"] {
240
6
        for c in form.chars() {
241
6
            apply(&mut app, Intent::InsertChar(c));
242
6
        }
243
2
        apply(&mut app, Intent::ConsoleSubmit);
244
    }
245
1
    apply(&mut app, Intent::ConsoleHistoryPrev);
246
1
    assert_eq!(app.console.input.buffer(), "(b)");
247
1
    apply(&mut app, Intent::ConsoleHistoryPrev);
248
1
    assert_eq!(app.console.input.buffer(), "(a)");
249
1
    apply(&mut app, Intent::ConsoleHistoryNext);
250
1
    assert_eq!(app.console.input.buffer(), "(b)");
251
1
}
252

            
253
#[test]
254
1
fn console_interrupt_without_eval_does_not_panic() {
255
1
    let mut app = make_app();
256
1
    app.console_focused = true;
257
1
    apply(&mut app, Intent::ConsoleInterrupt);
258
1
}
259

            
260
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
261
1
async fn console_interrupt_intent_reaches_attached_eval() {
262
    use crate::tabs::nms_eval::ConsoleEval;
263
    use rpc::{ScriptCtx, ScriptLimits};
264
    use std::time::Duration;
265
    use tokio::time::sleep;
266

            
267
1
    let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
268
1
        fuel: u64::MAX,
269
1
        ..ScriptLimits::default()
270
1
    });
271
1
    let eval = ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
272
1
    let mut app = make_app();
273
1
    app.attach_console(eval);
274
1
    app.console_focused = true;
275
42
    for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
276
42
        apply(&mut app, Intent::InsertChar(c));
277
42
    }
278
1
    apply(&mut app, Intent::ConsoleSubmit);
279
1
    sleep(Duration::from_millis(40)).await;
280
1
    apply(&mut app, Intent::ConsoleInterrupt);
281

            
282
1
    let mut interrupted = false;
283
1
    for _ in 0..200 {
284
61
        app.drain_console();
285
61
        if app
286
61
            .console
287
61
            .scrollback
288
61
            .iter()
289
62
            .any(|l| l.contains("[error] interrupted:"))
290
1
        {
291
1
            interrupted = true;
292
1
            break;
293
60
        }
294
60
        sleep(Duration::from_millis(20)).await;
295
1
    }
296
1
    assert!(interrupted, "interrupt did not reach the eval");
297
1
}
298

            
299
#[tokio::test]
300
1
async fn palette_account_list_submits_form_and_switches_console() {
301
    use crate::tabs::nms_eval::ConsoleEval;
302
1
    let mut app = make_app();
303
1
    app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
304
1
    apply(&mut app, Intent::OpenCommandLine);
305
12
    for c in "account list".chars() {
306
12
        apply(&mut app, Intent::InsertChar(c));
307
12
    }
308
1
    apply(&mut app, Intent::SubmitCommandLine);
309
1
    assert_eq!(app.active_tab, Tab::Console, "should switch to Console tab");
310
1
    assert!(
311
1
        app.console
312
1
            .scrollback
313
1
            .iter()
314
1
            .any(|l| l.contains("(list-accounts)")),
315
1
        "form should appear in scrollback; scrollback: {:?}",
316
1
        app.console.scrollback
317
1
    );
318
1
}
319

            
320
#[test]
321
1
fn palette_config_set_opens_modal_not_console() {
322
1
    let mut app = make_app();
323
1
    apply(&mut app, Intent::OpenCommandLine);
324
10
    for c in "config set".chars() {
325
10
        apply(&mut app, Intent::InsertChar(c));
326
10
    }
327
1
    apply(&mut app, Intent::SubmitCommandLine);
328
1
    assert!(!app.overlays.is_empty(), "config set should open a modal");
329
1
    assert_eq!(
330
        app.active_tab,
331
        Tab::Reports,
332
        "tab should not switch for modal path"
333
    );
334
1
}
335

            
336
#[test]
337
1
fn console_cycle_pane_toggles_focus() {
338
1
    let mut app = make_app();
339
1
    app.console_focused = true;
340
    use crate::pane::PaneId;
341
1
    assert_eq!(app.console.panes.focused(), PaneId::Prompt);
342
1
    apply(&mut app, Intent::ConsoleCyclePane);
343
1
    assert_eq!(app.console.panes.focused(), PaneId::Scrollback);
344
1
    apply(&mut app, Intent::ConsoleCyclePane);
345
1
    assert_eq!(app.console.panes.focused(), PaneId::Prompt);
346
1
}
347

            
348
#[test]
349
1
fn console_refocus_resets_pane_to_prompt() {
350
    use crate::pane::PaneId;
351
1
    let mut app = make_app();
352
1
    app.active_tab = Tab::Console;
353
1
    app.console_focused = true;
354
1
    apply(&mut app, Intent::ConsoleCyclePane);
355
1
    assert_eq!(app.console.panes.focused(), PaneId::Scrollback);
356
1
    apply(&mut app, Intent::ConsoleBlur);
357
1
    apply(&mut app, Intent::ConsoleFocus);
358
1
    assert_eq!(app.console.panes.focused(), PaneId::Prompt);
359
1
}
360

            
361
#[test]
362
1
fn scroll_line_up_down_adjusts_offset() {
363
1
    let mut app = make_app();
364
1
    app.console_focused = true;
365
20
    for i in 0..20 {
366
20
        app.console.push_scrollback(format!("line {i}"));
367
20
    }
368
1
    apply(&mut app, Intent::ScrollLineUp);
369
1
    assert_eq!(app.console.scroll, 1);
370
1
    apply(&mut app, Intent::ScrollLineUp);
371
1
    assert_eq!(app.console.scroll, 2);
372
1
    apply(&mut app, Intent::ScrollLineDown);
373
1
    assert_eq!(app.console.scroll, 1);
374
1
    apply(&mut app, Intent::ScrollLineDown);
375
1
    assert_eq!(app.console.scroll, 0);
376
1
}
377

            
378
#[test]
379
1
fn scroll_page_up_down_moves_by_ten() {
380
1
    let mut app = make_app();
381
1
    app.console_focused = true;
382
30
    for i in 0..30 {
383
30
        app.console.push_scrollback(format!("line {i}"));
384
30
    }
385
1
    apply(&mut app, Intent::ScrollPageUp);
386
1
    assert_eq!(app.console.scroll, 10);
387
1
    apply(&mut app, Intent::ScrollPageDown);
388
1
    assert_eq!(app.console.scroll, 0);
389
1
}
390

            
391
#[test]
392
1
fn scroll_up_clamped_at_scrollback_len() {
393
1
    let mut app = make_app();
394
1
    app.console_focused = true;
395
5
    for i in 0..5 {
396
5
        app.console.push_scrollback(format!("line {i}"));
397
5
    }
398
1
    apply(&mut app, Intent::ScrollPageUp);
399
1
    assert_eq!(app.console.scroll, 5);
400
1
}
401

            
402
#[test]
403
1
fn submit_console_form_resets_scroll() {
404
1
    let mut app = make_app();
405
20
    for i in 0..20 {
406
20
        app.console.push_scrollback(format!("line {i}"));
407
20
    }
408
1
    app.console.scroll_up(10);
409
1
    assert_eq!(app.console.scroll, 10);
410
1
    app.submit_console_form("(+ 1 2)".to_string());
411
1
    assert_eq!(app.console.scroll, 0);
412
1
}
413

            
414
#[test]
415
1
fn palette_account_balance_bad_uuid_surfaces_error_status() {
416
1
    let mut app = make_app();
417
1
    apply(&mut app, Intent::OpenCommandLine);
418
34
    for c in "account balance account=not-a-uuid".chars() {
419
34
        apply(&mut app, Intent::InsertChar(c));
420
34
    }
421
1
    apply(&mut app, Intent::SubmitCommandLine);
422
1
    assert!(
423
1
        app.status.contains("command error"),
424
        "bad uuid should surface error in status; got: {}",
425
        app.status
426
    );
427
1
}
428

            
429
#[test]
430
1
fn amount_widget_in_form_rejects_non_amount_chars() {
431
    use crate::form::{Field, Form, FormKind};
432
    use crate::widgets::{AmountWidget, Widget};
433

            
434
1
    let mut app = make_app();
435
1
    let form = Form {
436
1
        fields: vec![Field {
437
1
            label: "amount",
438
1
            widget: Widget::Amount(AmountWidget::new(EditMode::Emacs)),
439
1
        }],
440
1
        focus: 0,
441
1
        kind: FormKind::ConfigSet,
442
1
        entity_id: None,
443
1
    };
444
1
    app.overlays.push(Modal::Form(form));
445
1
    apply(&mut app, Intent::InsertChar('a')); // rejected
446
1
    apply(&mut app, Intent::InsertChar('5')); // accepted
447
1
    apply(&mut app, Intent::InsertChar('.')); // accepted
448
1
    match app.overlays.top() {
449
1
        Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "5."),
450
        _ => panic!("expected form modal"),
451
    }
452
1
}