Lines
97.55 %
Functions
100 %
Branches
//! Tests for ListDelete/ListEdit intents and the Confirm overlay flow.
use super::make_app;
use crate::event::{Intent, apply};
use crate::form::{Form, FormKind, validate};
use crate::modal::{ConfirmAction, Modal};
use crate::tabs::fetch::Fetch;
use crate::view::Tab;
use crate::widgets::EditMode;
use cli_core::render::ListRow;
/// Wire frame that yields a single transaction row with the given uuid.
fn transactions_wire(reply_id: u64, tx_uuid: &str) -> String {
format!(
r#"(:id {reply_id} :value "((:transaction :id \"{tx_uuid}\" :note \"test\" :post-date \"2026-01-01T00:00:00+00:00\"))")"#
)
}
/// Wire frame that yields a single account row with the given uuid.
fn accounts_wire(reply_id: u64, uuid: &str) -> String {
format!(r#"(:id {reply_id} :value "((:account :id \"{uuid}\" :name \"Cash\" :parent \"\"))")"#)
fn load_transactions(app: &mut crate::app::App, uuid: &str) {
app.transactions.on_reply(&transactions_wire(1, uuid));
fn load_accounts(app: &mut crate::app::App, uuid: &str) {
app.accounts.on_reply(&accounts_wire(2, uuid));
#[test]
fn list_delete_on_transactions_with_selected_id_pushes_confirm() {
let uuid = "550e8400-e29b-41d4-a716-446655440010";
let mut app = make_app();
app.active_tab = Tab::Transactions;
load_transactions(&mut app, uuid);
apply(&mut app, Intent::ListDelete);
let modal = app.overlays.top();
assert!(
matches!(modal, Some(Modal::Confirm { prompt, action: ConfirmAction::DeleteTransaction(id) })
if id == uuid && prompt.contains(uuid)),
"ListDelete must push a Confirm modal with the selected transaction id"
);
fn list_delete_without_loaded_list_does_nothing() {
// transactions is Idle — no selected id
app.overlays.is_empty(),
"no confirm pushed when no row loaded"
fn list_delete_on_non_transactions_tab_does_nothing() {
let uuid = "550e8400-e29b-41d4-a716-446655440011";
app.active_tab = Tab::Accounts;
// Even with a loaded account list, d on Accounts must not trigger delete.
load_accounts(&mut app, uuid);
"ListDelete on Accounts must not push a confirm"
fn confirm_cancel_esc_pops_overlay_without_dispatch() {
let uuid = "550e8400-e29b-41d4-a716-446655440012";
app.overlays.push(Modal::Confirm {
prompt: format!("Delete {uuid}?"),
action: ConfirmAction::DeleteTransaction(uuid.to_string()),
});
apply(&mut app, Intent::CloseTopmost);
assert!(app.overlays.is_empty(), "Esc must pop the confirm overlay");
// No eval was dispatched (no eval worker attached), so status stays empty.
assert!(app.status.is_empty(), "no side-effect on cancel");
fn confirm_cancel_via_close_topmost_leaves_no_overlay() {
let uuid = "550e8400-e29b-41d4-a716-446655440013";
prompt: "Delete?".to_string(),
assert!(app.overlays.is_empty());
fn confirm_yes_pops_overlay_and_sets_worker_stopped_status() {
// With no eval worker attached, ConfirmYes removes the modal and sets an
// error status — proving the dispatch path was taken rather than skipped.
let uuid = "550e8400-e29b-41d4-a716-446655440014";
apply(&mut app, Intent::ConfirmYes);
"ConfirmYes must pop the confirm modal"
app.status.contains("not connected") || app.status.contains("stopped"),
"eval dispatch must have been attempted, status: {}",
app.status
fn confirm_yes_with_form_overlay_does_not_submit() {
// A Form on top must be untouched by ConfirmYes: no pop, no eval, no status.
app.overlays
.push(Modal::Form(Form::commodity_create(EditMode::Emacs)));
matches!(app.overlays.top(), Some(Modal::Form(_))),
"ConfirmYes must leave a Form overlay in place"
app.status.is_empty(),
"ConfirmYes on a Form must not set status, got: {}",
fn confirm_yes_no_eval_keeps_console_not_connected_status() {
// submit_delete_transaction owns the failure status; execute_confirm must
// not clobber the precise "console not connected" message.
let uuid = "550e8400-e29b-41d4-a716-446655440099";
assert!(app.overlays.is_empty(), "ConfirmYes must pop the confirm");
assert_eq!(
app.status, "console not connected",
"precise failure status must survive"
fn list_edit_on_transactions_dispatches_edit_open() {
// ListEdit on the Transactions tab now opens the transaction-edit form
// (asynchronously, via get-transaction-detail). With no eval worker attached,
// the attempt sets "console not connected" instead of pushing a form.
// The transaction-tag form is still reachable via the palette ("transaction tag").
let uuid = "550e8400-e29b-41d4-a716-446655440015";
apply(&mut app, Intent::ListEdit);
"no form pushed synchronously (edit open is async)"
"dispatch attempt sets status when no eval worker"
fn list_edit_on_accounts_opens_account_tag_form() {
let uuid = "550e8400-e29b-41d4-a716-446655440016";
match app.overlays.top() {
Some(Modal::Form(f)) if f.kind == FormKind::AccountTag => {
f.entity_id.as_deref(),
Some(uuid),
"entity_id must be the selected account id"
other => panic!("expected AccountTag form, got {other:?}"),
fn list_edit_without_loaded_list_sets_status() {
// accounts is Idle — no selected id
"no form pushed when no account loaded"
app.status.contains("no account"),
"status must indicate no selection, got: {}",
// ── Form validation tests ───────────────────────────────────────────────────
fn validate_account_tag_empty_name_returns_err() {
let form = Form::account_tag(EditMode::Emacs, "uuid-abc".to_string());
// name field is empty — validation must reject it
let result = validate(&form);
matches!(result, Err(ref msg) if msg.contains("tag name")),
"empty tag name must fail validation, got: {result:?}"
fn validate_transaction_tag_empty_name_returns_err() {
let form = Form::transaction_tag(EditMode::Emacs, "uuid-abc".to_string());
fn validate_account_tag_with_name_returns_submit() {
use crate::form::FormSubmit;
let mut form = Form::account_tag(EditMode::Emacs, "acct-uuid".to_string());
// Type "category" into the Tag name field
if let Some(f) = form.focused_editor_mut() {
for c in "category".chars() {
f.insert_char(c);
// Move to value field and type "expenses"
form.cycle(true);
for c in "expenses".chars() {
matches!(
result,
Ok(FormSubmit::AccountTag {
ref account_id,
ref name,
ref value
}) if account_id == "acct-uuid" && name == "category" && value == "expenses"
),
"valid account tag form must produce AccountTag submit, got: {result:?}"
fn validate_transaction_tag_with_name_returns_submit() {
let mut form = Form::transaction_tag(EditMode::Emacs, "tx-uuid".to_string());
for c in "memo".chars() {
for c in "lunch".chars() {
Ok(FormSubmit::TransactionTag {
ref transaction_id,
}) if transaction_id == "tx-uuid" && name == "memo" && value == "lunch"
"valid transaction tag form must produce TransactionTag submit, got: {result:?}"
// ── Listing row selection helpers ───────────────────────────────────────────
fn selected_id_returns_none_when_list_idle() {
assert!(app.transactions.selected_id().is_none());
fn selected_id_returns_uuid_when_row_loaded() {
let uuid = "550e8400-e29b-41d4-a716-446655440017";
assert_eq!(app.transactions.selected_id(), Some(uuid));
// ── Manually inject a row without wire parsing ──────────────────────────────
fn loaded_list_tab_with_no_id_does_not_push_confirm() {
// Row without an id
app.transactions.state = Fetch::Loaded(vec![ListRow {
id: None,
cells: vec!["no-id-row".to_string()],
}]);
"no confirm pushed when selected row has no id"