Lines
99.14 %
Functions
100 %
Branches
//! Tests for key-event routing.
mod completion;
mod confirm;
mod select;
mod transaction;
use super::*;
use sqlx::types::Uuid;
fn make_app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
}
#[test]
fn quit_intent_sets_quit_flag() {
let mut app = make_app();
apply(&mut app, Intent::Quit);
assert!(app.should_quit);
fn next_tab_intent_advances_tab() {
app.active_tab = Tab::Accounts;
apply(&mut app, Intent::NextTab);
assert_eq!(app.active_tab, Tab::Transactions);
fn select_tab_intent_jumps_directly() {
apply(&mut app, Intent::SelectTab(Tab::Reports));
assert_eq!(app.active_tab, Tab::Reports);
fn open_command_line_activates_and_accepts_input() {
apply(&mut app, Intent::OpenCommandLine);
assert!(app.cmdline.active);
apply(&mut app, Intent::InsertChar('v'));
apply(&mut app, Intent::InsertChar('x'));
assert_eq!(app.cmdline.editor.buffer(), "vx");
fn command_line_escape_exits_when_already_in_normal_mode() {
app.set_edit_mode(EditMode::Vim);
app.cmdline.editor.enter_normal_mode();
apply(&mut app, Intent::CloseTopmost);
assert!(!app.cmdline.active);
fn command_line_escape_first_drops_vim_to_normal() {
assert_eq!(app.cmdline.editor.vim_mode(), VimMode::Insert);
assert!(app.cmdline.active, "first Esc should keep cmdline open");
assert_eq!(app.cmdline.editor.vim_mode(), VimMode::Normal);
fn submit_command_line_records_buffer_and_closes() {
for c in "version".chars() {
apply(&mut app, Intent::InsertChar(c));
apply(&mut app, Intent::SubmitCommandLine);
assert!(!app.cmdline.active, "command line must close after submit");
// version is an eval leaf — it submits the form and switches to Console.
assert_eq!(app.active_tab, Tab::Console);
assert!(
app.console
.scrollback
.iter()
.any(|l| l.contains("(get-version)")),
"form should appear in console scrollback"
);
fn submit_command_line_with_unknown_path_surfaces_error() {
for c in "bogus-cmd".chars() {
assert!(app.status.contains("unknown"));
fn report_params_modal_accepts_typing_and_focus_cycle() {
// `reports activity` with no dates opens the params modal; the user must be
// able to type into it, Tab to cycle focus, and have the buffers captured.
for c in "reports activity".chars() {
matches!(app.overlays.top(), Some(Modal::Form(_))),
"missing dates should open the report-params modal"
for c in "2024-01-01".chars() {
apply(&mut app, Intent::NextTab); // From → To
for c in "2024-12-31".chars() {
match app.overlays.top() {
Some(Modal::Form(f)) => {
assert_eq!(f.fields[0].widget.value(), "2024-01-01");
assert_eq!(f.fields[1].widget.value(), "2024-12-31");
other => panic!("expected Form modal, got {other:?}"),
fn submit_config_set_opens_form_modal() {
for c in "config set name=locale value=en".chars() {
assert!(!app.overlays.is_empty());
assert_eq!(f.fields[0].widget.value(), "locale");
assert_eq!(f.fields[1].widget.value(), "en");
fn open_help_pushes_a_modal() {
apply(&mut app, Intent::OpenHelp);
assert!(matches!(app.overlays.top(), Some(Modal::Help)));
fn close_topmost_pops_modal_before_touching_tabs() {
!app.should_quit,
"quit should be swallowed by the modal layer"
assert!(app.overlays.is_empty());
fn toggle_edit_mode_flips_emacs_vim() {
assert_eq!(app.edit_mode, EditMode::Emacs);
apply(&mut app, Intent::ToggleEditMode);
assert_eq!(app.edit_mode, EditMode::Vim);
fn console_focus_sets_flag_and_blur_clears_it() {
app.active_tab = Tab::Console;
apply(&mut app, Intent::ConsoleFocus);
assert!(app.console_focused);
apply(&mut app, Intent::ConsoleBlur);
assert!(!app.console_focused);
fn console_editing_intents_mutate_input_editor() {
app.console_focused = true;
apply(&mut app, Intent::InsertChar('('));
apply(&mut app, Intent::InsertChar('a'));
assert_eq!(app.console.input.buffer(), "(a");
apply(&mut app, Intent::DeleteBackward);
assert_eq!(app.console.input.buffer(), "(");
fn console_submit_incomplete_form_keeps_buffering() {
for c in "(list".chars() {
apply(&mut app, Intent::ConsoleSubmit);
assert_eq!(app.console.pending, "(list");
assert!(app.console.input.buffer().is_empty());
#[tokio::test]
async fn console_submit_routes_complete_form_to_echo_eval() {
use crate::tabs::nms_eval::ConsoleEval;
app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
for c in "(+ 1 2)".chars() {
tokio::task::yield_now().await;
app.drain_console();
assert!(app.console.scrollback.iter().any(|l| l == "> (+ 1 2)"));
.any(|l| l.contains("(:id 1 :form (+ 1 2))"))
fn console_history_keys_navigate_prior_submissions() {
for form in ["(a)", "(b)"] {
for c in form.chars() {
apply(&mut app, Intent::ConsoleHistoryPrev);
assert_eq!(app.console.input.buffer(), "(b)");
assert_eq!(app.console.input.buffer(), "(a)");
apply(&mut app, Intent::ConsoleHistoryNext);
fn console_interrupt_without_eval_does_not_panic() {
apply(&mut app, Intent::ConsoleInterrupt);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn console_interrupt_intent_reaches_attached_eval() {
use rpc::{ScriptCtx, ScriptLimits};
use std::time::Duration;
use tokio::time::sleep;
let ctx = ScriptCtx::new(Uuid::nil()).with_limits(ScriptLimits {
fuel: u64::MAX,
..ScriptLimits::default()
});
let eval = ConsoleEval::spawn_with_ctx(&tokio::runtime::Handle::current(), ctx).expect("spawn");
app.attach_console(eval);
for c in "(do ((i 0 (+ i 1))) ((>= i 2000000000) i))".chars() {
sleep(Duration::from_millis(40)).await;
let mut interrupted = false;
for _ in 0..200 {
if app
.console
.any(|l| l.contains("[error] interrupted:"))
{
interrupted = true;
break;
sleep(Duration::from_millis(20)).await;
assert!(interrupted, "interrupt did not reach the eval");
async fn palette_account_list_submits_form_and_switches_console() {
for c in "account list".chars() {
assert_eq!(app.active_tab, Tab::Console, "should switch to Console tab");
.any(|l| l.contains("(list-accounts)")),
"form should appear in scrollback; scrollback: {:?}",
app.console.scrollback
fn palette_config_set_opens_modal_not_console() {
for c in "config set".chars() {
assert!(!app.overlays.is_empty(), "config set should open a modal");
assert_eq!(
app.active_tab,
Tab::Reports,
"tab should not switch for modal path"
fn console_cycle_pane_toggles_focus() {
use crate::pane::PaneId;
assert_eq!(app.console.panes.focused(), PaneId::Prompt);
apply(&mut app, Intent::ConsoleCyclePane);
assert_eq!(app.console.panes.focused(), PaneId::Scrollback);
fn console_refocus_resets_pane_to_prompt() {
fn scroll_line_up_down_adjusts_offset() {
for i in 0..20 {
app.console.push_scrollback(format!("line {i}"));
apply(&mut app, Intent::ScrollLineUp);
assert_eq!(app.console.scroll, 1);
assert_eq!(app.console.scroll, 2);
apply(&mut app, Intent::ScrollLineDown);
assert_eq!(app.console.scroll, 0);
fn scroll_page_up_down_moves_by_ten() {
for i in 0..30 {
apply(&mut app, Intent::ScrollPageUp);
assert_eq!(app.console.scroll, 10);
apply(&mut app, Intent::ScrollPageDown);
fn scroll_up_clamped_at_scrollback_len() {
for i in 0..5 {
assert_eq!(app.console.scroll, 5);
fn submit_console_form_resets_scroll() {
app.console.scroll_up(10);
app.submit_console_form("(+ 1 2)".to_string());
fn palette_account_balance_bad_uuid_surfaces_error_status() {
for c in "account balance account=not-a-uuid".chars() {
app.status.contains("command error"),
"bad uuid should surface error in status; got: {}",
app.status
fn amount_widget_in_form_rejects_non_amount_chars() {
use crate::form::{Field, Form, FormKind};
use crate::widgets::{AmountWidget, Widget};
let form = Form {
fields: vec![Field {
label: "amount",
widget: Widget::Amount(AmountWidget::new(EditMode::Emacs)),
}],
focus: 0,
kind: FormKind::ConfigSet,
entity_id: None,
};
app.overlays.push(Modal::Form(form));
apply(&mut app, Intent::InsertChar('a')); // rejected
apply(&mut app, Intent::InsertChar('5')); // accepted
apply(&mut app, Intent::InsertChar('.')); // accepted
Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "5."),
_ => panic!("expected form modal"),