1
//! Tests for the transaction-create and transaction-edit modals.
2

            
3
use crate::app::App;
4
use crate::event::{Intent, apply};
5
use crate::form::{Form, FormKind, validate};
6
use crate::modal::Modal;
7
use crate::tabs::fetch::Fetch;
8
use crate::tabs::nms_eval::ConsoleEval;
9
use crate::view::Tab;
10
use crate::widgets::splits::COL_FROM;
11
use crate::widgets::{DateWidget, EditMode, SelectOption, Widget};
12
use cli_core::forms::{EditableRow, EditableTransaction};
13
use cli_core::render::ListRow;
14
use sqlx::types::Uuid;
15

            
16
6
fn make_app() -> App {
17
6
    App::new(Uuid::new_v4(), EditMode::Emacs)
18
6
}
19

            
20
3
fn editable_transaction(id: &str) -> EditableTransaction {
21
3
    EditableTransaction {
22
3
        id: id.to_string(),
23
3
        note: "Groceries".to_string(),
24
3
        date: "2024-06-01T00:00:00Z".to_string(),
25
3
        rows: vec![EditableRow {
26
3
            from_account: "aaaa-from".to_string(),
27
3
            to_account: "aaaa-to".to_string(),
28
3
            from_commodity: "cccc-usd".to_string(),
29
3
            to_commodity: "cccc-usd".to_string(),
30
3
            value: "42".to_string(),
31
3
            to_amount: None,
32
3
        }],
33
3
    }
34
3
}
35

            
36
1
fn loaded_transactions_app() -> App {
37
1
    let mut app = make_app();
38
1
    app.active_tab = Tab::Transactions;
39
1
    let uuid = "550e8400-e29b-41d4-a716-446655440000";
40
1
    app.transactions.state = Fetch::Loaded(vec![ListRow {
41
1
        id: Some(uuid.to_string()),
42
1
        cells: vec!["2024-06-01".to_string(), "Groceries".to_string()],
43
1
    }]);
44
1
    app
45
1
}
46

            
47
#[tokio::test]
48
1
async fn palette_transaction_create_opens_modal_and_fetches_options() {
49
1
    let mut app = make_app();
50
1
    app.attach_console(ConsoleEval::echo(&tokio::runtime::Handle::current()));
51
1
    apply(&mut app, Intent::OpenCommandLine);
52
18
    for c in "transaction create".chars() {
53
18
        apply(&mut app, Intent::InsertChar(c));
54
18
    }
55
1
    apply(&mut app, Intent::SubmitCommandLine);
56
1
    match app.overlays.top() {
57
1
        Some(Modal::Form(f)) => {
58
1
            assert_eq!(f.kind, FormKind::TransactionCreate);
59
1
            assert_eq!(f.fields.len(), 3, "date + note + splits");
60
        }
61
        other => panic!("expected TransactionCreate modal, got {other:?}"),
62
    }
63
    // The open form requested both option lists; the echo worker reflects
64
    // both frames, so draining yields no error notices (the worker is alive).
65
1
    tokio::task::yield_now().await;
66
1
    app.drain_eval();
67
1
    assert!(
68
1
        !app.console
69
1
            .scrollback
70
1
            .iter()
71
2
            .any(|l| l.contains("worker stopped")),
72
1
        "both option fetches must dispatch without stopping the worker"
73
1
    );
74
1
}
75

            
76
#[test]
77
1
fn split_row_select_widget_first_navigates_options() {
78
1
    let mut app = make_app();
79
1
    let mut form = Form::transaction_create(EditMode::Emacs);
80
1
    form.focus = 2;
81
1
    if let Widget::Splits(ref mut sw) = form.fields[2].widget {
82
1
        sw.col_focus = COL_FROM;
83
1
        sw.set_account_options(vec![
84
1
            SelectOption {
85
1
                id: "acc-0".to_string(),
86
1
                label: "Cash".to_string(),
87
1
            },
88
1
            SelectOption {
89
1
                id: "acc-1".to_string(),
90
1
                label: "Food".to_string(),
91
1
            },
92
1
        ]);
93
1
    }
94
1
    app.overlays.push(Modal::Form(form));
95
    // SelectNext drives the focused row's from-account Select (nested widget-first).
96
1
    apply(&mut app, Intent::SelectNext);
97
1
    match app.overlays.top() {
98
1
        Some(Modal::Form(f)) => {
99
1
            if let Widget::Splits(sw) = &f.fields[2].widget {
100
1
                assert_eq!(sw.rows()[0].from.value(), "acc-1");
101
            } else {
102
                panic!("field 2 must be Splits");
103
            }
104
        }
105
        other => panic!("expected form modal, got {other:?}"),
106
    }
107
1
}
108

            
109
// --- TransactionEdit tests ---
110

            
111
#[test]
112
1
fn transaction_edit_form_prefilled_from_editable_transaction() {
113
1
    let et = editable_transaction("tx-uuid-001");
114
1
    let form = Form::transaction_edit(EditMode::Emacs, &et);
115

            
116
1
    assert_eq!(form.kind, FormKind::TransactionEdit);
117
1
    assert_eq!(form.entity_id.as_deref(), Some("tx-uuid-001"));
118
1
    assert_eq!(form.fields.len(), 3, "date + note + splits");
119

            
120
    // Date and note pre-filled.
121
1
    assert_eq!(form.fields[0].widget.value(), "2024-06-01T00:00:00Z");
122
1
    assert_eq!(form.fields[1].widget.value(), "Groceries");
123

            
124
    // Splits pre-filled with stored uuids.
125
1
    if let Widget::Splits(ref sw) = form.fields[2].widget {
126
1
        let rows = sw.rows();
127
1
        assert_eq!(rows.len(), 1);
128
1
        assert_eq!(rows[0].from.value(), "aaaa-from", "from_account uuid");
129
1
        assert_eq!(rows[0].to.value(), "aaaa-to", "to_account uuid");
130
1
        assert_eq!(
131
1
            rows[0].from_commodity.value(),
132
            "cccc-usd",
133
            "from_commodity uuid"
134
        );
135
1
        assert_eq!(
136
1
            rows[0].to_commodity.value(),
137
            "cccc-usd",
138
            "to_commodity uuid"
139
        );
140
1
        assert_eq!(rows[0].value.value(), "42");
141
1
        assert_eq!(
142
1
            rows[0].to_amount.value(),
143
            "",
144
            "no to_amount for same-commodity"
145
        );
146
    } else {
147
        panic!("field 2 must be Splits");
148
    }
149
1
}
150

            
151
#[test]
152
1
fn transaction_edit_selects_preserve_uuid_after_options_fetch() {
153
1
    let et = editable_transaction("tx-uuid-002");
154
1
    let mut form = Form::transaction_edit(EditMode::Emacs, &et);
155

            
156
    // Simulate options fetch arriving after form open.
157
1
    if let Widget::Splits(ref mut sw) = form.fields[2].widget {
158
1
        sw.set_account_options(vec![
159
1
            SelectOption {
160
1
                id: "aaaa-other".to_string(),
161
1
                label: "Other Account".to_string(),
162
1
            },
163
1
            SelectOption {
164
1
                id: "aaaa-from".to_string(),
165
1
                label: "Cash Account".to_string(),
166
1
            },
167
1
            SelectOption {
168
1
                id: "aaaa-to".to_string(),
169
1
                label: "Food Account".to_string(),
170
1
            },
171
        ]);
172
1
        sw.set_commodity_options(vec![
173
1
            SelectOption {
174
1
                id: "cccc-eur".to_string(),
175
1
                label: "EUR".to_string(),
176
1
            },
177
1
            SelectOption {
178
1
                id: "cccc-usd".to_string(),
179
1
                label: "USD".to_string(),
180
1
            },
181
        ]);
182
1
        let rows = sw.rows();
183
1
        assert_eq!(
184
1
            rows[0].from.value(),
185
            "aaaa-from",
186
            "from uuid preserved after set_account_options"
187
        );
188
1
        assert_eq!(rows[0].to.value(), "aaaa-to", "to uuid preserved");
189
1
        assert_eq!(
190
1
            rows[0].from_commodity.value(),
191
            "cccc-usd",
192
            "from_commodity preserved"
193
        );
194
1
        assert_eq!(
195
1
            rows[0].to_commodity.value(),
196
            "cccc-usd",
197
            "to_commodity preserved"
198
        );
199
1
        assert_eq!(
200
1
            rows[0].from.display(),
201
            "Cash Account",
202
            "label updated to real label"
203
        );
204
    } else {
205
        panic!("field 2 must be Splits");
206
    }
207
1
}
208

            
209
#[test]
210
1
fn validate_transaction_edit_produces_edit_submit() {
211
    use crate::form::FormSubmit;
212

            
213
1
    let et = editable_transaction("tx-uuid-003");
214
1
    let form = Form::transaction_edit(EditMode::Emacs, &et);
215
1
    match validate(&form) {
216
        Ok(FormSubmit::TransactionEdit {
217
1
            id,
218
1
            note,
219
1
            date,
220
1
            splits,
221
        }) => {
222
1
            assert_eq!(id, "tx-uuid-003");
223
1
            assert_eq!(note, "Groceries");
224
1
            assert_eq!(date, "2024-06-01T00:00:00Z");
225
1
            assert_eq!(splits.len(), 1);
226
1
            assert_eq!(splits[0].from, "aaaa-from");
227
1
            assert_eq!(splits[0].to, "aaaa-to");
228
1
            assert_eq!(splits[0].amount, "42");
229
        }
230
        other => panic!("expected TransactionEdit submit, got {other:?}"),
231
    }
232
1
}
233

            
234
#[test]
235
1
fn list_edit_on_loaded_transactions_tab_dispatches_edit_async() {
236
    // With no eval worker attached, open_transaction_edit_async sets status
237
    // "console not connected" — proving the dispatch path was taken.
238
1
    let mut app = loaded_transactions_app();
239
1
    apply(&mut app, Intent::ListEdit);
240
1
    assert_eq!(
241
        app.status, "console not connected",
242
        "ListEdit on Transactions should attempt to fetch transaction detail"
243
    );
244
1
    assert!(
245
1
        app.overlays.is_empty(),
246
        "no form pushed synchronously (async open)"
247
    );
248
1
}
249

            
250
#[test]
251
1
fn list_edit_on_transactions_with_no_selection_sets_status() {
252
1
    let mut app = make_app();
253
1
    app.active_tab = Tab::Transactions;
254
    // transactions list is Idle (no loaded rows).
255
1
    apply(&mut app, Intent::ListEdit);
256
1
    assert_eq!(app.status, "no transaction selected");
257
1
    assert!(app.overlays.is_empty());
258
1
}
259

            
260
#[test]
261
1
fn date_step_forward_intent_advances_date_field_by_one_day() {
262
1
    let mut app = make_app();
263
1
    let mut form = Form::transaction_create(EditMode::Emacs);
264
    // Seed a known date in the date field so the step outcome is deterministic.
265
1
    if let Widget::Date(ref mut dw) = form.fields[0].widget {
266
1
        *dw = DateWidget::with_value(EditMode::Emacs, "2024-06-15T10:00");
267
1
    }
268
1
    form.focus = 0;
269
1
    app.overlays.push(Modal::Form(form));
270
1
    apply(&mut app, Intent::DateStepForward);
271
1
    match app.overlays.top() {
272
1
        Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "2024-06-16T10:00"),
273
        other => panic!("expected form modal, got {other:?}"),
274
    }
275
1
}
276

            
277
#[test]
278
1
fn date_step_back_intent_retreats_date_field_by_one_day() {
279
1
    let mut app = make_app();
280
1
    let mut form = Form::transaction_create(EditMode::Emacs);
281
1
    if let Widget::Date(ref mut dw) = form.fields[0].widget {
282
1
        *dw = DateWidget::with_value(EditMode::Emacs, "2024-06-15T10:00");
283
1
    }
284
1
    form.focus = 0;
285
1
    app.overlays.push(Modal::Form(form));
286
1
    apply(&mut app, Intent::DateStepBack);
287
1
    match app.overlays.top() {
288
1
        Some(Modal::Form(f)) => assert_eq!(f.fields[0].widget.value(), "2024-06-14T10:00"),
289
        other => panic!("expected form modal, got {other:?}"),
290
    }
291
1
}