Lines
95.88 %
Functions
100 %
Branches
//! Tests for the transaction-create and transaction-edit modals.
use crate::app::App;
use crate::event::{Intent, apply};
use crate::form::{Form, FormKind, validate};
use crate::modal::Modal;
use crate::tabs::fetch::Fetch;
use crate::tabs::nms_eval::ConsoleEval;
use crate::view::Tab;
use crate::widgets::splits::COL_FROM;
use crate::widgets::{DateWidget, EditMode, SelectOption, Widget};
use cli_core::forms::{EditableRow, EditableTransaction};
use cli_core::render::ListRow;
use sqlx::types::Uuid;
fn make_app() -> App {
App::new(Uuid::new_v4(), EditMode::Emacs)
}
fn editable_transaction(id: &str) -> EditableTransaction {
EditableTransaction {
id: id.to_string(),
note: "Groceries".to_string(),
date: "2024-06-01T00:00:00Z".to_string(),
rows: vec![EditableRow {
from_account: "aaaa-from".to_string(),
to_account: "aaaa-to".to_string(),
from_commodity: "cccc-usd".to_string(),
to_commodity: "cccc-usd".to_string(),
value: "42".to_string(),
to_amount: None,
}],
fn loaded_transactions_app() -> App {
let mut app = make_app();
app.active_tab = Tab::Transactions;
let uuid = "550e8400-e29b-41d4-a716-446655440000";
app.transactions.state = Fetch::Loaded(vec![ListRow {
id: Some(uuid.to_string()),
cells: vec!["2024-06-01".to_string(), "Groceries".to_string()],
}]);
app
#[tokio::test]
async fn palette_transaction_create_opens_modal_and_fetches_options() {
app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
apply(&mut app, Intent::OpenCommandLine);
for c in "transaction create".chars() {
apply(&mut app, Intent::InsertChar(c));
apply(&mut app, Intent::SubmitCommandLine);
match app.overlays.top() {
Some(Modal::Form(f)) => {
assert_eq!(f.kind, FormKind::TransactionCreate);
assert_eq!(f.fields.len(), 3, "date + note + splits");
other => panic!("expected TransactionCreate modal, got {other:?}"),
// The open form requested both option lists; the echo worker reflects
// both frames, so draining yields no error notices (the worker is alive).
tokio::task::yield_now().await;
app.drain_eval();
assert!(
!app.console
.scrollback
.iter()
.any(|l| l.contains("worker stopped")),
"both option fetches must dispatch without stopping the worker"
);
#[test]
fn split_row_select_widget_first_navigates_options() {
let mut form = Form::transaction_create(EditMode::Emacs);
form.focus = 2;
if let Widget::Splits(ref mut sw) = form.fields[2].widget {
sw.col_focus = COL_FROM;
sw.set_account_options(vec![
SelectOption {
id: "acc-0".to_string(),
label: "Cash".to_string(),
},
id: "acc-1".to_string(),
label: "Food".to_string(),
]);
app.overlays.push(Modal::Form(form));
// SelectNext drives the focused row's from-account Select (nested widget-first).
apply(&mut app, Intent::SelectNext);
if let Widget::Splits(sw) = &f.fields[2].widget {
assert_eq!(sw.rows()[0].from.value(), "acc-1");
} else {
panic!("field 2 must be Splits");
other => panic!("expected form modal, got {other:?}"),
// --- TransactionEdit tests ---
fn transaction_edit_form_prefilled_from_editable_transaction() {
let et = editable_transaction("tx-uuid-001");
let form = Form::transaction_edit(EditMode::Emacs, &et);
assert_eq!(form.kind, FormKind::TransactionEdit);
assert_eq!(form.entity_id.as_deref(), Some("tx-uuid-001"));
assert_eq!(form.fields.len(), 3, "date + note + splits");
// Date and note pre-filled.
assert_eq!(form.fields[0].widget.value(), "2024-06-01T00:00:00Z");
assert_eq!(form.fields[1].widget.value(), "Groceries");
// Splits pre-filled with stored uuids.
if let Widget::Splits(ref sw) = form.fields[2].widget {
let rows = sw.rows();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].from.value(), "aaaa-from", "from_account uuid");
assert_eq!(rows[0].to.value(), "aaaa-to", "to_account uuid");
assert_eq!(
rows[0].from_commodity.value(),
"cccc-usd",
"from_commodity uuid"
rows[0].to_commodity.value(),
"to_commodity uuid"
assert_eq!(rows[0].value.value(), "42");
rows[0].to_amount.value(),
"",
"no to_amount for same-commodity"
fn transaction_edit_selects_preserve_uuid_after_options_fetch() {
let et = editable_transaction("tx-uuid-002");
let mut form = Form::transaction_edit(EditMode::Emacs, &et);
// Simulate options fetch arriving after form open.
id: "aaaa-other".to_string(),
label: "Other Account".to_string(),
id: "aaaa-from".to_string(),
label: "Cash Account".to_string(),
id: "aaaa-to".to_string(),
label: "Food Account".to_string(),
sw.set_commodity_options(vec![
id: "cccc-eur".to_string(),
label: "EUR".to_string(),
id: "cccc-usd".to_string(),
label: "USD".to_string(),
rows[0].from.value(),
"aaaa-from",
"from uuid preserved after set_account_options"
assert_eq!(rows[0].to.value(), "aaaa-to", "to uuid preserved");
"from_commodity preserved"
"to_commodity preserved"
rows[0].from.display(),
"Cash Account",
"label updated to real label"
fn validate_transaction_edit_produces_edit_submit() {
use crate::form::FormSubmit;
let et = editable_transaction("tx-uuid-003");
match validate(&form) {
Ok(FormSubmit::TransactionEdit {
id,
note,
date,
splits,
}) => {
assert_eq!(id, "tx-uuid-003");
assert_eq!(note, "Groceries");
assert_eq!(date, "2024-06-01T00:00:00Z");
assert_eq!(splits.len(), 1);
assert_eq!(splits[0].from, "aaaa-from");
assert_eq!(splits[0].to, "aaaa-to");
assert_eq!(splits[0].amount, "42");
other => panic!("expected TransactionEdit submit, got {other:?}"),
fn list_edit_on_loaded_transactions_tab_dispatches_edit_async() {
// With no eval worker attached, open_transaction_edit_async sets status
// "console not connected" — proving the dispatch path was taken.
let mut app = loaded_transactions_app();
apply(&mut app, Intent::ListEdit);
app.status, "console not connected",
"ListEdit on Transactions should attempt to fetch transaction detail"
app.overlays.is_empty(),
"no form pushed synchronously (async open)"
fn list_edit_on_transactions_with_no_selection_sets_status() {
// transactions list is Idle (no loaded rows).
assert_eq!(app.status, "no transaction selected");
assert!(app.overlays.is_empty());
fn date_step_forward_intent_advances_date_field_by_one_day() {
// Seed a known date in the date field so the step outcome is deterministic.
if let Widget::Date(ref mut dw) = form.fields[0].widget {
*dw = DateWidget::with_value(EditMode::Emacs, "2024-06-15T10:00");
form.focus = 0;
apply(&mut app, Intent::DateStepForward);
Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "2024-06-16T10:00"),
fn date_step_back_intent_retreats_date_field_by_one_day() {
apply(&mut app, Intent::DateStepBack);
Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "2024-06-14T10:00"),