1
//! Tests for ListDelete/ListEdit intents and the Confirm overlay flow.
2

            
3
use super::make_app;
4
use crate::event::{Intent, apply};
5
use crate::form::{Form, FormKind, validate};
6
use crate::modal::{ConfirmAction, Modal};
7
use crate::tabs::fetch::Fetch;
8
use crate::view::Tab;
9
use crate::widgets::EditMode;
10
use cli_core::render::ListRow;
11

            
12
/// Wire frame that yields a single transaction row with the given uuid.
13
3
fn transactions_wire(reply_id: u64, tx_uuid: &str) -> String {
14
3
    format!(
15
        r#"(:id {reply_id} :value "((:transaction :id \"{tx_uuid}\" :note \"test\" :post-date \"2026-01-01T00:00:00+00:00\"))")"#
16
    )
17
3
}
18

            
19
/// Wire frame that yields a single account row with the given uuid.
20
2
fn accounts_wire(reply_id: u64, uuid: &str) -> String {
21
2
    format!(r#"(:id {reply_id} :value "((:account :id \"{uuid}\" :name \"Cash\" :parent \"\"))")"#)
22
2
}
23

            
24
2
fn load_transactions(app: &mut crate::app::App, uuid: &str) {
25
2
    app.transactions.on_reply(&transactions_wire(1, uuid));
26
2
}
27

            
28
2
fn load_accounts(app: &mut crate::app::App, uuid: &str) {
29
2
    app.accounts.on_reply(&accounts_wire(2, uuid));
30
2
}
31

            
32
#[test]
33
1
fn list_delete_on_transactions_with_selected_id_pushes_confirm() {
34
1
    let uuid = "550e8400-e29b-41d4-a716-446655440010";
35
1
    let mut app = make_app();
36
1
    app.active_tab = Tab::Transactions;
37
1
    load_transactions(&mut app, uuid);
38

            
39
1
    apply(&mut app, Intent::ListDelete);
40

            
41
1
    let modal = app.overlays.top();
42
1
    assert!(
43
1
        matches!(modal, Some(Modal::Confirm { prompt, action: ConfirmAction::DeleteTransaction(id) })
44
1
            if id == uuid && prompt.contains(uuid)),
45
        "ListDelete must push a Confirm modal with the selected transaction id"
46
    );
47
1
}
48

            
49
#[test]
50
1
fn list_delete_without_loaded_list_does_nothing() {
51
1
    let mut app = make_app();
52
1
    app.active_tab = Tab::Transactions;
53
    // transactions is Idle — no selected id
54
1
    apply(&mut app, Intent::ListDelete);
55
1
    assert!(
56
1
        app.overlays.is_empty(),
57
        "no confirm pushed when no row loaded"
58
    );
59
1
}
60

            
61
#[test]
62
1
fn list_delete_on_non_transactions_tab_does_nothing() {
63
1
    let uuid = "550e8400-e29b-41d4-a716-446655440011";
64
1
    let mut app = make_app();
65
1
    app.active_tab = Tab::Accounts;
66
    // Even with a loaded account list, d on Accounts must not trigger delete.
67
1
    load_accounts(&mut app, uuid);
68
1
    apply(&mut app, Intent::ListDelete);
69
1
    assert!(
70
1
        app.overlays.is_empty(),
71
        "ListDelete on Accounts must not push a confirm"
72
    );
73
1
}
74

            
75
#[test]
76
1
fn confirm_cancel_esc_pops_overlay_without_dispatch() {
77
1
    let uuid = "550e8400-e29b-41d4-a716-446655440012";
78
1
    let mut app = make_app();
79
1
    app.overlays.push(Modal::Confirm {
80
1
        prompt: format!("Delete {uuid}?"),
81
1
        action: ConfirmAction::DeleteTransaction(uuid.to_string()),
82
1
    });
83

            
84
1
    apply(&mut app, Intent::CloseTopmost);
85

            
86
1
    assert!(app.overlays.is_empty(), "Esc must pop the confirm overlay");
87
    // No eval was dispatched (no eval worker attached), so status stays empty.
88
1
    assert!(app.status.is_empty(), "no side-effect on cancel");
89
1
}
90

            
91
#[test]
92
1
fn confirm_cancel_via_close_topmost_leaves_no_overlay() {
93
1
    let uuid = "550e8400-e29b-41d4-a716-446655440013";
94
1
    let mut app = make_app();
95
1
    app.overlays.push(Modal::Confirm {
96
1
        prompt: "Delete?".to_string(),
97
1
        action: ConfirmAction::DeleteTransaction(uuid.to_string()),
98
1
    });
99
1
    apply(&mut app, Intent::CloseTopmost);
100
1
    assert!(app.overlays.is_empty());
101
1
}
102

            
103
#[test]
104
1
fn confirm_yes_pops_overlay_and_sets_worker_stopped_status() {
105
    // With no eval worker attached, ConfirmYes removes the modal and sets an
106
    // error status — proving the dispatch path was taken rather than skipped.
107
1
    let uuid = "550e8400-e29b-41d4-a716-446655440014";
108
1
    let mut app = make_app();
109
1
    app.overlays.push(Modal::Confirm {
110
1
        prompt: format!("Delete {uuid}?"),
111
1
        action: ConfirmAction::DeleteTransaction(uuid.to_string()),
112
1
    });
113

            
114
1
    apply(&mut app, Intent::ConfirmYes);
115

            
116
1
    assert!(
117
1
        app.overlays.is_empty(),
118
        "ConfirmYes must pop the confirm modal"
119
    );
120
1
    assert!(
121
1
        app.status.contains("not connected") || app.status.contains("stopped"),
122
        "eval dispatch must have been attempted, status: {}",
123
        app.status
124
    );
125
1
}
126

            
127
#[test]
128
1
fn confirm_yes_with_form_overlay_does_not_submit() {
129
    // A Form on top must be untouched by ConfirmYes: no pop, no eval, no status.
130
1
    let mut app = make_app();
131
1
    app.overlays
132
1
        .push(Modal::Form(Form::commodity_create(EditMode::Emacs)));
133

            
134
1
    apply(&mut app, Intent::ConfirmYes);
135

            
136
1
    assert!(
137
1
        matches!(app.overlays.top(), Some(Modal::Form(_))),
138
        "ConfirmYes must leave a Form overlay in place"
139
    );
140
1
    assert!(
141
1
        app.status.is_empty(),
142
        "ConfirmYes on a Form must not set status, got: {}",
143
        app.status
144
    );
145
1
}
146

            
147
#[test]
148
1
fn confirm_yes_no_eval_keeps_console_not_connected_status() {
149
    // submit_delete_transaction owns the failure status; execute_confirm must
150
    // not clobber the precise "console not connected" message.
151
1
    let uuid = "550e8400-e29b-41d4-a716-446655440099";
152
1
    let mut app = make_app();
153
1
    app.overlays.push(Modal::Confirm {
154
1
        prompt: format!("Delete {uuid}?"),
155
1
        action: ConfirmAction::DeleteTransaction(uuid.to_string()),
156
1
    });
157

            
158
1
    apply(&mut app, Intent::ConfirmYes);
159

            
160
1
    assert!(app.overlays.is_empty(), "ConfirmYes must pop the confirm");
161
1
    assert_eq!(
162
        app.status, "console not connected",
163
        "precise failure status must survive"
164
    );
165
1
}
166

            
167
#[test]
168
1
fn list_edit_on_transactions_dispatches_edit_open() {
169
    // ListEdit on the Transactions tab now opens the transaction-edit form
170
    // (asynchronously, via get-transaction-detail). With no eval worker attached,
171
    // the attempt sets "console not connected" instead of pushing a form.
172
    // The transaction-tag form is still reachable via the palette ("transaction tag").
173
1
    let uuid = "550e8400-e29b-41d4-a716-446655440015";
174
1
    let mut app = make_app();
175
1
    app.active_tab = Tab::Transactions;
176
1
    load_transactions(&mut app, uuid);
177

            
178
1
    apply(&mut app, Intent::ListEdit);
179

            
180
1
    assert!(
181
1
        app.overlays.is_empty(),
182
        "no form pushed synchronously (edit open is async)"
183
    );
184
1
    assert_eq!(
185
        app.status, "console not connected",
186
        "dispatch attempt sets status when no eval worker"
187
    );
188
1
}
189

            
190
#[test]
191
1
fn list_edit_on_accounts_opens_account_tag_form() {
192
1
    let uuid = "550e8400-e29b-41d4-a716-446655440016";
193
1
    let mut app = make_app();
194
1
    app.active_tab = Tab::Accounts;
195
1
    load_accounts(&mut app, uuid);
196

            
197
1
    apply(&mut app, Intent::ListEdit);
198

            
199
1
    match app.overlays.top() {
200
1
        Some(Modal::Form(f)) if f.kind == FormKind::AccountTag => {
201
1
            assert_eq!(
202
1
                f.entity_id.as_deref(),
203
1
                Some(uuid),
204
                "entity_id must be the selected account id"
205
            );
206
        }
207
        other => panic!("expected AccountTag form, got {other:?}"),
208
    }
209
1
}
210

            
211
#[test]
212
1
fn list_edit_without_loaded_list_sets_status() {
213
1
    let mut app = make_app();
214
1
    app.active_tab = Tab::Accounts;
215
    // accounts is Idle — no selected id
216
1
    apply(&mut app, Intent::ListEdit);
217
1
    assert!(
218
1
        app.overlays.is_empty(),
219
        "no form pushed when no account loaded"
220
    );
221
1
    assert!(
222
1
        app.status.contains("no account"),
223
        "status must indicate no selection, got: {}",
224
        app.status
225
    );
226
1
}
227

            
228
// ── Form validation tests ───────────────────────────────────────────────────
229

            
230
#[test]
231
1
fn validate_account_tag_empty_name_returns_err() {
232
1
    let form = Form::account_tag(EditMode::Emacs, "uuid-abc".to_string());
233
    // name field is empty — validation must reject it
234
1
    let result = validate(&form);
235
1
    assert!(
236
1
        matches!(result, Err(ref msg) if msg.contains("tag name")),
237
        "empty tag name must fail validation, got: {result:?}"
238
    );
239
1
}
240

            
241
#[test]
242
1
fn validate_transaction_tag_empty_name_returns_err() {
243
1
    let form = Form::transaction_tag(EditMode::Emacs, "uuid-abc".to_string());
244
1
    let result = validate(&form);
245
1
    assert!(
246
1
        matches!(result, Err(ref msg) if msg.contains("tag name")),
247
        "empty tag name must fail validation, got: {result:?}"
248
    );
249
1
}
250

            
251
#[test]
252
1
fn validate_account_tag_with_name_returns_submit() {
253
    use crate::form::FormSubmit;
254
1
    let mut form = Form::account_tag(EditMode::Emacs, "acct-uuid".to_string());
255
    // Type "category" into the Tag name field
256
1
    if let Some(f) = form.focused_editor_mut() {
257
8
        for c in "category".chars() {
258
8
            f.insert_char(c);
259
8
        }
260
    }
261
    // Move to value field and type "expenses"
262
1
    form.cycle(true);
263
1
    if let Some(f) = form.focused_editor_mut() {
264
8
        for c in "expenses".chars() {
265
8
            f.insert_char(c);
266
8
        }
267
    }
268
1
    let result = validate(&form);
269
1
    assert!(
270
1
        matches!(
271
1
            result,
272
            Ok(FormSubmit::AccountTag {
273
1
                ref account_id,
274
1
                ref name,
275
1
                ref value
276
1
            }) if account_id == "acct-uuid" && name == "category" && value == "expenses"
277
        ),
278
        "valid account tag form must produce AccountTag submit, got: {result:?}"
279
    );
280
1
}
281

            
282
#[test]
283
1
fn validate_transaction_tag_with_name_returns_submit() {
284
    use crate::form::FormSubmit;
285
1
    let mut form = Form::transaction_tag(EditMode::Emacs, "tx-uuid".to_string());
286
1
    if let Some(f) = form.focused_editor_mut() {
287
4
        for c in "memo".chars() {
288
4
            f.insert_char(c);
289
4
        }
290
    }
291
1
    form.cycle(true);
292
1
    if let Some(f) = form.focused_editor_mut() {
293
5
        for c in "lunch".chars() {
294
5
            f.insert_char(c);
295
5
        }
296
    }
297
1
    let result = validate(&form);
298
1
    assert!(
299
1
        matches!(
300
1
            result,
301
            Ok(FormSubmit::TransactionTag {
302
1
                ref transaction_id,
303
1
                ref name,
304
1
                ref value
305
1
            }) if transaction_id == "tx-uuid" && name == "memo" && value == "lunch"
306
        ),
307
        "valid transaction tag form must produce TransactionTag submit, got: {result:?}"
308
    );
309
1
}
310

            
311
// ── Listing row selection helpers ───────────────────────────────────────────
312

            
313
#[test]
314
1
fn selected_id_returns_none_when_list_idle() {
315
1
    let mut app = make_app();
316
1
    app.active_tab = Tab::Transactions;
317
1
    assert!(app.transactions.selected_id().is_none());
318
1
}
319

            
320
#[test]
321
1
fn selected_id_returns_uuid_when_row_loaded() {
322
1
    let uuid = "550e8400-e29b-41d4-a716-446655440017";
323
1
    let mut app = make_app();
324
1
    app.transactions.on_reply(&transactions_wire(1, uuid));
325
1
    assert_eq!(app.transactions.selected_id(), Some(uuid));
326
1
}
327

            
328
// ── Manually inject a row without wire parsing ──────────────────────────────
329

            
330
#[test]
331
1
fn loaded_list_tab_with_no_id_does_not_push_confirm() {
332
1
    let mut app = make_app();
333
1
    app.active_tab = Tab::Transactions;
334
    // Row without an id
335
1
    app.transactions.state = Fetch::Loaded(vec![ListRow {
336
1
        id: None,
337
1
        cells: vec!["no-id-row".to_string()],
338
1
    }]);
339
1
    apply(&mut app, Intent::ListDelete);
340
1
    assert!(
341
1
        app.overlays.is_empty(),
342
        "no confirm pushed when selected row has no id"
343
    );
344
1
}