Lines
99.77 %
Functions
100 %
Branches
mod transaction;
use super::*;
use crate::form::Form;
use crate::modal::Modal;
use crate::route::{Route, RouteCtx};
use crate::tabs::config::ConfigCell;
use crate::tabs::fetch::Fetch;
use crate::tabs::nms_eval::ConsoleEval;
use crate::tabs::reports::ReportKind;
use crate::view::{Tab, ViewId};
use crate::widgets::{EditMode, SelectOption, Widget};
use cli_core::render::ListRow;
use sqlx::types::Uuid;
fn make() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
}
fn none_option() -> SelectOption {
SelectOption {
id: String::new(),
label: "(none)".to_string(),
/// One-account list-accounts reply wire for the parent-Select fetch.
fn accounts_wire(id: u64, uuid: &str, name: &str) -> String {
format!(r#"(:id {id} :value "((:account :id \"{uuid}\" :name \"{name}\" :parent \"\"))")"#)
/// Inspect the open account-create form's parent Select.
fn account_form_select(app: &App) -> Option<&crate::widgets::SelectWidget> {
let Some(Modal::Form(form)) = app.overlays.top() else {
return None;
};
form.fields.iter().find_map(|f| match &f.widget {
Widget::Select(sw) => Some(sw),
_ => None,
})
#[test]
fn all_has_six_tabs_ending_in_console() {
assert_eq!(Tab::ALL.len(), 6);
assert_eq!(Tab::ALL[Tab::ALL.len() - 1], Tab::Console);
fn console_label_is_console() {
assert_eq!(Tab::Console.label(), "Console");
fn next_tab_wraps_around() {
let mut app = make();
app.active_tab = Tab::Console;
app.next_tab();
assert_eq!(app.active_tab, Tab::Accounts);
fn previous_tab_wraps_around() {
app.active_tab = Tab::Accounts;
app.previous_tab();
assert_eq!(app.active_tab, Tab::Console);
fn next_tab_advances_in_order() {
assert_eq!(app.active_tab, Tab::Transactions);
assert_eq!(app.active_tab, Tab::Commodities);
fn switch_tab_sets_target() {
app.switch_tab(Tab::Reports);
assert_eq!(app.active_tab, Tab::Reports);
fn open_and_close_command_line() {
assert!(!app.cmdline.active);
app.open_command_line();
assert!(app.cmdline.active);
app.close_command_line();
fn request_quit_sets_flag() {
assert!(!app.should_quit);
app.request_quit();
assert!(app.should_quit);
fn set_edit_mode_propagates_to_command_line() {
app.cmdline.editor.insert_char('x');
app.set_edit_mode(EditMode::Vim);
assert_eq!(app.cmdline.editor.mode(), EditMode::Vim);
#[tokio::test]
async fn submit_then_drain_routes_echo_into_scrollback() {
app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
app.submit_console_form("(x)".to_string());
// Yield so the echo worker runs and answers before draining.
tokio::task::yield_now().await;
app.drain_console();
assert!(app.console.scrollback.iter().any(|l| l == "> (x)"));
// The echo worker reflects the request frame "(:id 1 :form (x))".
// parse_response rejects it (no :value/:error) → Unroutable → pushed
// verbatim as an "[error] …" line.
assert!(
app.console
.scrollback
.iter()
.any(|l| l.contains("(:id 1 :form (x))"))
);
fn submit_without_eval_pushes_not_connected_notice() {
.any(|l| l.contains("console not connected"))
fn drain_without_eval_is_noop() {
assert!(app.console.scrollback.is_empty());
async fn submit_after_worker_stops_surfaces_notice() {
let eval = ConsoleEval::echo(&tokio::runtime::Handle::current());
let worker = eval.worker_handle();
app.attach_console(eval);
worker.abort();
for _ in 0..200 {
if worker.is_finished() {
break;
.any(|l| l == "eval worker stopped")
async fn drain_eval_routes_console_reply_to_scrollback() {
app.submit_console_form("42".to_string());
app.drain_eval();
// The echo worker reflects the request frame "(:id 1 :form 42)", which
// parse_response rejects → Unroutable → pushed to scrollback as an
// "[error] …" line, so scrollback is non-empty.
assert!(!app.console.scrollback.is_empty());
async fn drain_eval_notice_goes_to_scrollback() {
fn switching_away_from_console_clears_focus() {
app.console_focused = true;
app.switch_tab(Tab::Accounts);
!app.console_focused,
"leaving console must drop its input focus"
assert!(!app.console_focused, "next_tab off console must drop focus");
fn refresh_tab_resets_to_idle() {
app.accounts.state = Fetch::Loaded(vec![ListRow {
id: None,
cells: vec!["foo".into()],
}]);
app.accounts.selected = 2;
app.refresh_tab(Tab::Accounts);
assert_eq!(app.accounts.state, Fetch::Idle);
assert_eq!(app.accounts.selected, 0);
fn switch_tab_to_reports_does_not_trigger_fetch() {
let app = make();
assert!(matches!(app.accounts.state, Fetch::Idle));
assert!(matches!(app.transactions.state, Fetch::Idle));
assert!(matches!(app.commodities.state, Fetch::Idle));
fn refresh_tab_while_loading_is_noop() {
app.accounts.state = Fetch::Loading { id: 7 };
// A fetch is in flight — refresh must not reset it or submit again.
assert!(matches!(app.accounts.state, Fetch::Loading { id: 7 }));
async fn worker_stop_fails_inflight_tabs_and_clears_routes() {
// A list fetch is in flight when the worker dies.
app.accounts.state = Fetch::Loading { id: 5 };
app.pending_routes.insert(
5,
Route {
target: ViewId::Accounts,
ctx: RouteCtx::None,
},
matches!(app.accounts.state, Fetch::Error(_)),
"stuck-loading tab must fail: {:?}",
app.accounts.state
app.pending_routes.is_empty(),
"routes must be cleared on worker stop"
fn wire_error(id: u64, code: &str, msg: &str) -> String {
format!("(:id {id} :error (:code {code} :message \"{msg}\"))")
fn wire_value(id: u64, val: &str) -> String {
format!("(:id {id} :value {val})")
fn deliver_reply_accounts_updates_accounts_state() {
let wire = wire_error(1, "db", "connection failed");
app.deliver_reply(
&wire,
matches!(app.accounts.state, Fetch::Error(ref s) if s.contains("db")),
"accounts state must reflect the error reply"
fn deliver_reply_transactions_updates_transactions_state() {
let wire = wire_error(1, "tx", "failed");
target: ViewId::Transactions,
assert!(matches!(app.transactions.state, Fetch::Error(_)));
fn deliver_reply_commodities_updates_commodities_state() {
let wire = wire_error(1, "c", "failed");
target: ViewId::Commodities,
assert!(matches!(app.commodities.state, Fetch::Error(_)));
fn deliver_reply_config_updates_config_cell() {
let wire = wire_error(1, "cfg", "not found");
target: ViewId::Config,
ctx: RouteCtx::Config {
key: "locale".to_string(),
let cell = app
.config
.entries
.find(|(k, _)| k == "locale")
.map(|(_, c)| c);
matches!(cell, Some(ConfigCell::Error(_))),
"config entry must reflect the error reply"
fn deliver_reply_reports_updates_reports_state() {
let wire = wire_error(1, "rpt", "failed");
target: ViewId::Reports,
ctx: RouteCtx::Reports {
kind: ReportKind::Balance,
chart: "bar".to_string(),
assert!(matches!(app.reports.state, Fetch::Error(_)));
fn deliver_reply_console_pushes_to_scrollback() {
let wire = wire_value(1, "42");
target: ViewId::Console,
!app.console.scrollback.is_empty(),
"console reply must appear in scrollback"
fn orphan_reply_sets_warn_status_and_pushes_scrollback() {
let wire = wire_value(99, "42");
// No route is present for id=99 → orphan branch fires.
app.route_reply(99, &wire);
app.status.contains("[warn] orphan reply id=99"),
"status must carry the orphan id; got {:?}",
app.status
"orphan reply must be echoed to scrollback"
fn deliver_reply_mutation_success_refreshes_accounts() {
cells: vec!["existing".into()],
let wire = wire_value(1, "\"550e8400-e29b-41d4-a716-446655440001\"");
ctx: RouteCtx::Mutation {
refresh: ViewId::Accounts,
matches!(app.accounts.state, Fetch::Idle),
"accounts must be reset to Idle after successful mutation"
fn deliver_reply_mutation_error_sets_status() {
let wire = wire_error(1, "constraint", "account exists");
app.status.contains("mutation failed"),
"status must reflect mutation error, got: {}",
fn deliver_reply_mutation_success_refreshes_commodities() {
app.commodities.state = Fetch::Loaded(vec![ListRow {
cells: vec!["USD".into()],
refresh: ViewId::Commodities,
matches!(app.commodities.state, Fetch::Idle),
"commodities must be reset to Idle after successful mutation"
fn deliver_reply_form_options_stale_seq_is_dropped() {
app.overlays.push(Modal::Form(Form::account_create(
EditMode::Emacs,
vec![none_option()],
)));
app.form_options_seq = 2;
let wire = accounts_wire(9, "550e8400-e29b-41d4-a716-446655440001", "Root");
ctx: RouteCtx::FormOptions {
seq: 1,
source: crate::route::FormOptionsSource::Accounts,
assert_eq!(
account_form_select(&app).map(crate::widgets::SelectWidget::option_count),
Some(1),
"a stale-seq reply must not populate the parent Select"
fn deliver_reply_form_options_matching_seq_populates() {
app.form_options_seq = 1;
let uuid = "550e8400-e29b-41d4-a716-446655440001";
let wire = accounts_wire(9, uuid, "Root");
let sw = account_form_select(&app).expect("account form select present");
assert_eq!(sw.option_count(), 2, "(none) + the one account");
fn deliver_reply_form_options_second_reply_does_not_clobber_selection() {
let root = "550e8400-e29b-41d4-a716-446655440001";
let opts = vec![
none_option(),
id: root.to_string(),
label: "Root".to_string(),
];
app.overlays
.push(Modal::Form(Form::account_create(EditMode::Emacs, opts)));
// User selects "Root".
if let Some(Modal::Form(form)) = app.overlays.top_mut()
&& let Widget::Select(ref mut sw) = form.fields[1].widget
{
sw.next();
// A second reply for the same form arrives with a different account.
let wire = accounts_wire(9, "550e8400-e29b-41d4-a716-446655440099", "Other");
assert_eq!(sw.option_count(), 2, "populated Select must not be reset");
assert_eq!(sw.value(), root, "user's parent selection must survive");