tests/input_tests.rs 26.9 K raw
1
//! State-machine tests for the TUI: synthetic key events drive `input::handle_key`
2
//! directly (no terminal needed).
3
4
use std::path::PathBuf;
5
6
use cielago::app::{App, EditTarget, EditorTab, Focus, Mode, Popup, SidebarRow};
7
use cielago::input::handle_key;
8
use cielago::model::{Collection, KeyValueRow, LabelMode, Method, SavedRequest};
9
use cielago::store::AppConfig;
10
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
11
12
fn key(code: KeyCode) -> KeyEvent {
13
    KeyEvent::new(code, KeyModifiers::NONE)
14
}
15
16
fn char_key(c: char) -> KeyEvent {
17
    key(KeyCode::Char(c))
18
}
19
20
fn type_str(app: &mut App, s: &str) {
21
    for c in s.chars() {
22
        handle_key(app, char_key(c));
23
    }
24
}
25
26
fn test_collection() -> Collection {
27
    let mut c = Collection::new("test");
28
    c.servers = vec![
29
        "https://one.example.com".into(),
30
        "https://two.example.com".into(),
31
    ];
32
33
    let mut list = SavedRequest::blank("listPets");
34
    list.method = Method::Get;
35
    list.path = "/pets".into();
36
    list.tags = vec!["pets".into()];
37
    list.query.push(KeyValueRow::new("limit", "20", false));
38
    list.query.push(KeyValueRow::new("filter", "", false));
39
40
    let mut create = SavedRequest::blank("createPet");
41
    create.method = Method::Post;
42
    create.path = "/pets".into();
43
    create.tags = vec!["pets".into()];
44
    create.body = Some("{\n  \"name\": \"Fido\"\n}".into());
45
46
    let mut order = SavedRequest::blank("placeOrder");
47
    order.method = Method::Post;
48
    order.path = "/orders".into();
49
    order.tags = vec!["store".into()];
50
51
    c.requests = vec![list, create, order];
52
    c
53
}
54
55
fn test_app() -> App {
56
    App::new(
57
        test_collection(),
58
        PathBuf::from("/tmp/cielago-test.json"),
59
        AppConfig::default(),
60
    )
61
}
62
63
#[test]
64
fn startup_state() {
65
    let app = test_app();
66
    assert_eq!(app.mode, Mode::Normal);
67
    // sidebar: group header + 2 pets requests + group + 1 store request
68
    assert_eq!(app.sidebar_rows.len(), 5);
69
    // first request auto-selected, but focus starts on the sidebar
70
    assert_eq!(app.selected, Some(0));
71
    assert_eq!(app.focus, Focus::Sidebar);
72
}
73
74
#[test]
75
fn startup_restores_the_last_open_request() {
76
    let mut c = test_collection();
77
    c.last_request = Some(c.requests[2].id);
78
    let app = App::new(
79
        c,
80
        PathBuf::from("/tmp/cielago-test.json"),
81
        AppConfig::default(),
82
    );
83
    assert_eq!(app.selected, Some(2));
84
    // the sidebar cursor lands on it too, not back at the top
85
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
86
    assert_eq!(app.focus, Focus::Sidebar);
87
}
88
89
#[test]
90
fn startup_expands_the_restored_request_group() {
91
    let mut c = test_collection();
92
    c.groups_collapsed = true;
93
    c.last_request = Some(c.requests[2].id);
94
    let app = App::new(
95
        c,
96
        PathBuf::from("/tmp/cielago-test.json"),
97
        AppConfig::default(),
98
    );
99
    // "store" is expanded so the restored row is visible; "pets" stays collapsed
100
    assert_eq!(app.selected, Some(2));
101
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
102
    assert_eq!(app.sidebar_rows.len(), 3);
103
}
104
105
#[test]
106
fn startup_restores_the_focused_pane_and_tab() {
107
    let mut c = test_collection();
108
    c.last_request = Some(c.requests[1].id);
109
    c.last_focus = Some(Focus::Editor);
110
    c.last_tab = Some(EditorTab::Body);
111
    let app = App::new(
112
        c,
113
        PathBuf::from("/tmp/cielago-test.json"),
114
        AppConfig::default(),
115
    );
116
    assert_eq!(app.selected, Some(1));
117
    assert_eq!(app.focus, Focus::Editor);
118
    assert_eq!(app.tab, EditorTab::Body);
119
}
120
121
#[test]
122
fn startup_skips_a_saved_response_pane_with_no_response() {
123
    let mut c = test_collection();
124
    c.last_focus = Some(Focus::Response);
125
    c.last_tab = Some(EditorTab::Docs);
126
    let app = App::new(
127
        c,
128
        PathBuf::from("/tmp/cielago-test.json"),
129
        AppConfig::default(),
130
    );
131
    // responses aren't persisted, so pane 3 would be empty
132
    assert_eq!(app.focus, Focus::Editor);
133
    assert_eq!(app.tab, EditorTab::Docs);
134
}
135
136
// `record_view` rather than `save`: saving writes into the real
137
// `~/.config/cielago/collections`, which a test has no business touching.
138
#[test]
139
fn record_view_captures_request_pane_and_tab() {
140
    let mut app = test_app();
141
    handle_key(&mut app, char_key('j')); // onto the first request row
142
    handle_key(&mut app, char_key('j')); // onto the second
143
    handle_key(&mut app, key(KeyCode::Enter)); // open it
144
    handle_key(&mut app, char_key(']')); // Params -> Headers
145
    handle_key(&mut app, char_key(']')); // Headers -> Body
146
    handle_key(&mut app, char_key('3')); // response pane
147
    app.record_view();
148
149
    let id = app.collection.requests[1].id;
150
    assert_eq!(app.collection.last_request, Some(id));
151
    assert_eq!(app.collection.last_focus, Some(Focus::Response));
152
    assert_eq!(app.collection.last_tab, Some(EditorTab::Body));
153
}
154
155
#[test]
156
fn startup_ignores_a_stale_last_request() {
157
    let mut c = test_collection();
158
    c.last_request = Some(uuid::Uuid::new_v4());
159
    let app = App::new(
160
        c,
161
        PathBuf::from("/tmp/cielago-test.json"),
162
        AppConfig::default(),
163
    );
164
    // request is gone (re-imported spec, deleted operation): fall back to first
165
    assert_eq!(app.selected, Some(0));
166
}
167
168
#[test]
169
fn help_popup_opens_and_closes() {
170
    let mut app = test_app();
171
    handle_key(&mut app, char_key('?'));
172
    assert_eq!(app.popup, Popup::Help);
173
    handle_key(&mut app, key(KeyCode::Esc));
174
    assert_eq!(app.popup, Popup::None);
175
}
176
177
#[test]
178
fn z_toggles_pane_zoom_without_touching_focus() {
179
    let mut app = test_app();
180
    handle_key(&mut app, char_key('2'));
181
    handle_key(&mut app, char_key('z'));
182
    assert!(app.zoom);
183
    assert_eq!(app.focus, Focus::Editor);
184
    // Focus still moves while zoomed — it just picks the maximized pane.
185
    handle_key(&mut app, key(KeyCode::Tab));
186
    assert!(app.zoom);
187
    assert_eq!(app.focus, Focus::Response);
188
    handle_key(&mut app, char_key('z'));
189
    assert!(!app.zoom);
190
}
191
192
#[test]
193
fn sidebar_navigation_and_selection() {
194
    let mut app = test_app();
195
    handle_key(&mut app, char_key('1'));
196
    assert_eq!(app.focus, Focus::Sidebar);
197
    handle_key(&mut app, char_key('j'));
198
    assert_eq!(app.sidebar_sel, 1);
199
    handle_key(&mut app, char_key('j'));
200
    handle_key(&mut app, key(KeyCode::Enter));
201
    assert_eq!(app.selected, Some(1)); // createPet
202
    assert_eq!(app.focus, Focus::Editor);
203
    // body loaded into textarea
204
    assert!(app.textarea.lines().join("\n").contains("Fido"));
205
}
206
207
#[test]
208
fn sidebar_group_collapse() {
209
    let mut app = test_app();
210
    handle_key(&mut app, char_key('1'));
211
    // row 0 is the "pets" group
212
    handle_key(&mut app, key(KeyCode::Enter));
213
    assert!(app.collapsed.contains("pets"));
214
    assert_eq!(app.sidebar_rows.len(), 3); // pets group collapsed
215
    handle_key(&mut app, key(KeyCode::Enter));
216
    assert!(!app.collapsed.contains("pets"));
217
    assert_eq!(app.sidebar_rows.len(), 5);
218
}
219
220
#[test]
221
fn quit_guards_unsaved_changes() {
222
    let mut app = test_app();
223
    assert!(!app.dirty);
224
    handle_key(&mut app, char_key('q'));
225
    assert!(app.should_quit);
226
227
    let mut app = test_app();
228
    app.dirty = true;
229
    handle_key(&mut app, char_key('q'));
230
    assert!(!app.should_quit);
231
    assert!(app.status.contains("Unsaved"));
232
    // :q! forces
233
    handle_key(&mut app, char_key(':'));
234
    type_str(&mut app, "q!");
235
    handle_key(&mut app, key(KeyCode::Enter));
236
    assert!(app.should_quit);
237
}
238
239
#[test]
240
fn tab_cycling() {
241
    let mut app = test_app();
242
    assert_eq!(app.tab, EditorTab::Params);
243
    handle_key(&mut app, char_key(']'));
244
    assert_eq!(app.tab, EditorTab::Headers);
245
    handle_key(&mut app, char_key(']'));
246
    assert_eq!(app.tab, EditorTab::Body);
247
    handle_key(&mut app, char_key('['));
248
    assert_eq!(app.tab, EditorTab::Headers);
249
}
250
251
#[test]
252
fn tab_cycling_letter_aliases() {
253
    let mut app = test_app();
254
    assert_eq!(app.tab, EditorTab::Params);
255
    handle_key(&mut app, char_key('L'));
256
    assert_eq!(app.tab, EditorTab::Headers);
257
    handle_key(&mut app, char_key('L'));
258
    assert_eq!(app.tab, EditorTab::Body);
259
    handle_key(&mut app, char_key('H'));
260
    assert_eq!(app.tab, EditorTab::Headers);
261
    // wraps backwards past Params into Variables
262
    handle_key(&mut app, char_key('H'));
263
    handle_key(&mut app, char_key('H'));
264
    assert_eq!(app.tab, EditorTab::Variables);
265
}
266
267
#[test]
268
fn slash_filters_the_sidebar() {
269
    let mut app = test_app();
270
    handle_key(&mut app, char_key('/'));
271
    assert_eq!(app.mode, Mode::Search);
272
    assert_eq!(app.focus, Focus::Sidebar);
273
274
    type_str(&mut app, "order");
275
    // "store" group header + placeOrder only
276
    assert_eq!(app.sidebar_rows.len(), 2);
277
    // cursor parked on the match, not the group header
278
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(2));
279
280
    // Enter keeps the filter and returns to Normal.
281
    handle_key(&mut app, key(KeyCode::Enter));
282
    assert_eq!(app.mode, Mode::Normal);
283
    assert_eq!(app.filter, "order");
284
    handle_key(&mut app, key(KeyCode::Enter)); // open the match
285
    assert_eq!(app.selected, Some(2));
286
287
    // Esc in the sidebar clears the filter.
288
    handle_key(&mut app, char_key('1'));
289
    handle_key(&mut app, key(KeyCode::Esc));
290
    assert!(app.filter.is_empty());
291
    assert_eq!(app.sidebar_rows.len(), 5);
292
}
293
294
#[test]
295
fn search_matches_path_method_and_tag() {
296
    let mut app = test_app();
297
298
    handle_key(&mut app, char_key('/'));
299
    type_str(&mut app, "/pets");
300
    assert_eq!(app.sidebar_rows.len(), 3); // pets group + 2 requests
301
    handle_key(&mut app, key(KeyCode::Esc));
302
    assert!(app.filter.is_empty());
303
304
    handle_key(&mut app, char_key('/'));
305
    type_str(&mut app, "post");
306
    assert_eq!(app.sidebar_rows.len(), 4); // createPet + placeOrder, 2 groups
307
    handle_key(&mut app, key(KeyCode::Esc));
308
309
    handle_key(&mut app, char_key('/'));
310
    type_str(&mut app, "store");
311
    assert_eq!(app.sidebar_rows.len(), 2);
312
313
    // backspacing widens the match set again
314
    handle_key(&mut app, key(KeyCode::Backspace));
315
    handle_key(&mut app, key(KeyCode::Backspace));
316
    handle_key(&mut app, key(KeyCode::Backspace));
317
    handle_key(&mut app, key(KeyCode::Backspace));
318
    handle_key(&mut app, key(KeyCode::Backspace));
319
    assert_eq!(app.sidebar_rows.len(), 5);
320
}
321
322
#[test]
323
fn search_shows_matches_inside_collapsed_groups() {
324
    let mut app = test_app();
325
    app.collapsed.insert("pets".into());
326
    app.rebuild_sidebar();
327
    assert_eq!(app.sidebar_rows.len(), 3);
328
329
    handle_key(&mut app, char_key('/'));
330
    type_str(&mut app, "createPet");
331
    assert_eq!(app.sidebar_rows.len(), 2);
332
}
333
334
#[test]
335
fn label_mode_cycles_and_persists_on_the_collection() {
336
    let mut app = test_app();
337
    handle_key(&mut app, char_key('1'));
338
    assert_eq!(app.collection.label_mode, LabelMode::Name);
339
    handle_key(&mut app, char_key('t'));
340
    assert_eq!(app.collection.label_mode, LabelMode::Summary);
341
    handle_key(&mut app, char_key('t'));
342
    assert_eq!(app.collection.label_mode, LabelMode::Path);
343
    handle_key(&mut app, char_key('t'));
344
    assert_eq!(app.collection.label_mode, LabelMode::Name);
345
    assert!(app.dirty);
346
}
347
348
#[test]
349
fn label_command_sets_mode() {
350
    let mut app = test_app();
351
    handle_key(&mut app, char_key(':'));
352
    type_str(&mut app, "label path");
353
    handle_key(&mut app, key(KeyCode::Enter));
354
    assert_eq!(app.collection.label_mode, LabelMode::Path);
355
356
    handle_key(&mut app, char_key(':'));
357
    type_str(&mut app, "label nonsense");
358
    handle_key(&mut app, key(KeyCode::Enter));
359
    assert_eq!(app.collection.label_mode, LabelMode::Path);
360
    assert!(app.status.contains("Usage"));
361
}
362
363
#[test]
364
fn rename_all_rewrites_names_from_paths() {
365
    let mut app = test_app();
366
    handle_key(&mut app, char_key(':'));
367
    type_str(&mut app, "rename-all method-path");
368
    handle_key(&mut app, key(KeyCode::Enter));
369
    assert_eq!(app.collection.requests[0].name, "GET /pets");
370
    assert_eq!(app.collection.requests[1].name, "POST /pets");
371
    assert_eq!(app.collection.requests[2].name, "POST /orders");
372
    assert!(app.dirty);
373
}
374
375
#[test]
376
fn edit_query_value_inline() {
377
    let mut app = test_app();
378
    handle_key(&mut app, char_key('2'));
379
    assert_eq!(app.tab, EditorTab::Params);
380
    // row 0 = limit (value "20")
381
    handle_key(&mut app, char_key('i'));
382
    assert_eq!(app.mode, Mode::Insert);
383
    assert!(matches!(app.editing, Some(EditTarget::Cell { .. })));
384
    handle_key(&mut app, char_key('5'));
385
    handle_key(&mut app, key(KeyCode::Enter));
386
    assert_eq!(app.mode, Mode::Normal);
387
    assert_eq!(app.collection.requests[0].query[0].value, "205");
388
    assert!(app.dirty);
389
}
390
391
#[test]
392
fn space_toggles_row() {
393
    let mut app = test_app();
394
    handle_key(&mut app, char_key('2'));
395
    assert!(!app.collection.requests[0].query[0].enabled);
396
    handle_key(&mut app, char_key(' '));
397
    assert!(app.collection.requests[0].query[0].enabled);
398
}
399
400
#[test]
401
fn add_header_row_with_uuid_variable() {
402
    let mut app = test_app();
403
    handle_key(&mut app, char_key('2'));
404
    handle_key(&mut app, char_key(']')); // Headers tab
405
    assert_eq!(app.tab, EditorTab::Headers);
406
    handle_key(&mut app, char_key('a'));
407
    type_str(&mut app, "X-Request-Id");
408
    handle_key(&mut app, key(KeyCode::Enter)); // chains to value edit
409
    type_str(&mut app, "{{uuid}}");
410
    handle_key(&mut app, key(KeyCode::Enter));
411
    assert_eq!(app.mode, Mode::Normal);
412
    let headers = &app.collection.requests[0].headers;
413
    assert_eq!(headers.len(), 1);
414
    assert_eq!(headers[0].key, "X-Request-Id");
415
    assert_eq!(headers[0].value, "{{uuid}}");
416
    assert!(headers[0].enabled);
417
}
418
419
#[test]
420
fn body_textarea_editing() {
421
    let mut app = test_app();
422
    // select createPet (has a body)
423
    app.select_request(1);
424
    handle_key(&mut app, char_key(']'));
425
    handle_key(&mut app, char_key(']')); // Body tab
426
    assert_eq!(app.tab, EditorTab::Body);
427
    handle_key(&mut app, char_key('i'));
428
    assert_eq!(app.mode, Mode::Insert);
429
    handle_key(&mut app, key(KeyCode::Esc));
430
    assert_eq!(app.mode, Mode::Normal);
431
    assert!(
432
        app.collection.requests[1]
433
            .body
434
            .as_ref()
435
            .unwrap()
436
            .contains("Fido")
437
    );
438
}
439
440
#[test]
441
fn body_tab_scrolls_the_read_only_view() {
442
    let mut app = test_app();
443
    app.select_request(1);
444
    app.set_textarea_text(&(1..=40).map(|i| format!("line {i}\n")).collect::<String>());
445
    app.tab = EditorTab::Body;
446
447
    // The highlighted body view follows the textarea cursor.
448
    handle_key(&mut app, char_key('j'));
449
    handle_key(&mut app, char_key('j'));
450
    assert_eq!(app.textarea.cursor().0, 2);
451
    handle_key(&mut app, char_key('k'));
452
    assert_eq!(app.textarea.cursor().0, 1);
453
    handle_key(&mut app, char_key('d'));
454
    assert_eq!(app.textarea.cursor().0, 16);
455
    handle_key(&mut app, char_key('u'));
456
    assert_eq!(app.textarea.cursor().0, 1);
457
    handle_key(&mut app, char_key('G'));
458
    assert!(app.textarea.cursor().0 >= 39);
459
    handle_key(&mut app, char_key('g'));
460
    assert_eq!(app.textarea.cursor().0, 0);
461
462
    // `d` scrolls here rather than deleting a row, but the other editor keys
463
    // still reach their handlers.
464
    assert_eq!(app.collection.requests[1].method, Method::Post);
465
    handle_key(&mut app, char_key('m'));
466
    assert_eq!(app.collection.requests[1].method, Method::Put);
467
    handle_key(&mut app, char_key('i'));
468
    assert_eq!(app.mode, Mode::Insert);
469
}
470
471
#[test]
472
fn docs_tab_scrolls_and_stays_read_only() {
473
    let mut app = test_app();
474
    app.select_request(0); // moves focus to the editor
475
    app.tab = EditorTab::Docs;
476
    let params_before = app.collection.requests[0].query.len();
477
478
    handle_key(&mut app, char_key('j'));
479
    handle_key(&mut app, char_key('j'));
480
    assert_eq!(app.docs_scroll, 2);
481
    handle_key(&mut app, char_key('k'));
482
    assert_eq!(app.docs_scroll, 1);
483
    handle_key(&mut app, char_key('d'));
484
    assert_eq!(app.docs_scroll, 16);
485
    handle_key(&mut app, char_key('u'));
486
    assert_eq!(app.docs_scroll, 1);
487
    handle_key(&mut app, char_key('g'));
488
    assert_eq!(app.docs_scroll, 0);
489
490
    // `d` scrolled instead of deleting, and `i` doesn't open an editor here.
491
    assert_eq!(app.collection.requests[0].query.len(), params_before);
492
    handle_key(&mut app, char_key('i'));
493
    assert_eq!(app.mode, Mode::Normal);
494
    assert!(!app.dirty);
495
496
    // Opening another request resets the scroll.
497
    app.docs_scroll = 5;
498
    app.select_request(2);
499
    assert_eq!(app.docs_scroll, 0);
500
}
501
502
#[test]
503
fn help_popup_scrolls() {
504
    let mut app = test_app();
505
    handle_key(&mut app, char_key('?'));
506
    assert_eq!(app.help_scroll, 0);
507
    handle_key(&mut app, char_key('j'));
508
    handle_key(&mut app, char_key('j'));
509
    assert_eq!(app.help_scroll, 2);
510
    handle_key(&mut app, char_key('k'));
511
    assert_eq!(app.help_scroll, 1);
512
    handle_key(&mut app, char_key('g'));
513
    assert_eq!(app.help_scroll, 0);
514
    // Reopening starts back at the top.
515
    handle_key(&mut app, char_key('d'));
516
    assert!(app.help_scroll > 0);
517
    handle_key(&mut app, key(KeyCode::Esc));
518
    handle_key(&mut app, char_key('?'));
519
    assert_eq!(app.help_scroll, 0);
520
}
521
522
#[test]
523
fn env_popup_add_and_select_server() {
524
    let mut app = test_app();
525
    handle_key(&mut app, char_key('E'));
526
    assert_eq!(app.popup, Popup::Env);
527
    handle_key(&mut app, char_key('a'));
528
    type_str(&mut app, "http://localhost:8080");
529
    handle_key(&mut app, key(KeyCode::Enter));
530
    assert_eq!(app.collection.servers.len(), 3);
531
    assert_eq!(app.collection.active_server, 2);
532
    handle_key(&mut app, key(KeyCode::Esc));
533
    assert_eq!(app.popup, Popup::None);
534
}
535
536
#[test]
537
fn auth_popup_edits_and_applies() {
538
    let mut app = test_app();
539
    handle_key(&mut app, char_key('A'));
540
    assert_eq!(app.popup, Popup::Auth);
541
    // field 0 = token url
542
    handle_key(&mut app, char_key('i'));
543
    type_str(&mut app, "https://auth.example.com/token");
544
    handle_key(&mut app, key(KeyCode::Enter));
545
    // move to client id, edit
546
    handle_key(&mut app, char_key('j'));
547
    handle_key(&mut app, char_key('i'));
548
    type_str(&mut app, "my-client");
549
    handle_key(&mut app, key(KeyCode::Enter));
550
    // style toggle: field 4
551
    for _ in 0..3 {
552
        handle_key(&mut app, char_key('j'));
553
    }
554
    assert_eq!(app.auth_field, 4);
555
    handle_key(&mut app, char_key(' '));
556
    // close + apply
557
    handle_key(&mut app, key(KeyCode::Esc));
558
    assert_eq!(app.popup, Popup::None);
559
    let auth = app.collection.auth.as_ref().unwrap();
560
    assert_eq!(auth.token_url, "https://auth.example.com/token");
561
    assert_eq!(auth.client_id, "my-client");
562
    assert_eq!(auth.auth_style, cielago::model::AuthStyle::Post);
563
    assert!(app.dirty);
564
}
565
566
#[test]
567
fn new_request_flow() {
568
    let mut app = test_app();
569
    handle_key(&mut app, char_key('1'));
570
    handle_key(&mut app, char_key('n'));
571
    type_str(&mut app, "my custom request");
572
    handle_key(&mut app, key(KeyCode::Enter));
573
    assert_eq!(app.collection.requests.len(), 4);
574
    assert_eq!(app.selected, Some(3));
575
    assert_eq!(app.collection.requests[3].name, "my custom request");
576
}
577
578
#[test]
579
fn rename_and_delete_request() {
580
    let mut app = test_app();
581
    handle_key(&mut app, char_key('1'));
582
    handle_key(&mut app, char_key('j')); // first request row
583
    handle_key(&mut app, char_key('r'));
584
    // rename input prefilled with current name; replace
585
    handle_key(&mut app, key(KeyCode::Home));
586
    for _ in 0..20 {
587
        handle_key(&mut app, key(KeyCode::Delete));
588
    }
589
    type_str(&mut app, "renamed");
590
    handle_key(&mut app, key(KeyCode::Enter));
591
    assert_eq!(app.collection.requests[0].name, "renamed");
592
593
    // focus stays in the sidebar on the renamed row; delete it
594
    assert_eq!(app.focus, Focus::Sidebar);
595
    handle_key(&mut app, char_key('d'));
596
    assert_eq!(app.collection.requests.len(), 2);
597
    assert!(!app.collection.requests.iter().any(|r| r.name == "renamed"));
598
}
599
600
#[test]
601
fn variables_tab_roundtrip() {
602
    let mut app = test_app();
603
    handle_key(&mut app, char_key('2'));
604
    handle_key(&mut app, char_key('[')); // Variables (prev of Params)
605
    assert_eq!(app.tab, EditorTab::Variables);
606
    handle_key(&mut app, char_key('a'));
607
    type_str(&mut app, "tenant");
608
    handle_key(&mut app, key(KeyCode::Enter));
609
    type_str(&mut app, "acme");
610
    handle_key(&mut app, key(KeyCode::Enter));
611
    assert_eq!(app.collection.variables.len(), 1);
612
    let map = cielago::model::variables_map(&app.collection.variables);
613
    assert_eq!(map.get("tenant").unwrap(), "acme");
614
}
615
616
// ----- ad-hoc requests and collections -----
617
618
/// Open the URL prompt on the selected request with a cleared buffer.
619
fn start_url_edit(app: &mut App) {
620
    handle_key(app, char_key('2'));
621
    handle_key(app, char_key('p'));
622
    assert_eq!(app.editing, Some(EditTarget::Url));
623
    handle_key(app, key(KeyCode::Home));
624
    for _ in 0..60 {
625
        handle_key(app, key(KeyCode::Delete));
626
    }
627
}
628
629
fn type_url(app: &mut App, url: &str) {
630
    start_url_edit(app);
631
    type_str(app, url);
632
    handle_key(app, key(KeyCode::Enter));
633
}
634
635
#[test]
636
fn edit_url_sets_path_and_syncs_path_params() {
637
    let mut app = test_app();
638
    type_url(&mut app, "/pets/{petId}/photos");
639
640
    let req = &app.collection.requests[0];
641
    assert_eq!(req.path, "/pets/{petId}/photos");
642
    assert_eq!(req.path_params.len(), 1);
643
    assert_eq!(req.path_params[0].key, "petId");
644
    assert!(app.dirty);
645
    // No `?` in the input, so the existing query rows are untouched.
646
    assert_eq!(req.query.len(), 2);
647
648
    // Removing the placeholder prunes the row again.
649
    type_url(&mut app, "/pets");
650
    assert!(app.collection.requests[0].path_params.is_empty());
651
}
652
653
#[test]
654
fn pasting_a_full_url_adds_and_activates_the_server() {
655
    let mut app = test_app();
656
    type_url(
657
        &mut app,
658
        "https://three.example.com/v2/pets?limit=5&sort=name",
659
    );
660
661
    assert_eq!(app.collection.servers.len(), 3);
662
    assert_eq!(app.collection.active_server, 2);
663
    assert_eq!(app.collection.base_url(), Some("https://three.example.com"));
664
    let req = &app.collection.requests[0];
665
    assert_eq!(req.path, "/v2/pets");
666
    let query: Vec<(&str, &str)> = req
667
        .query
668
        .iter()
669
        .map(|r| (r.key.as_str(), r.value.as_str()))
670
        .collect();
671
    assert_eq!(query, vec![("limit", "5"), ("sort", "name")]);
672
    assert!(req.query.iter().all(|r| r.enabled));
673
}
674
675
#[test]
676
fn pasting_a_known_origin_switches_to_it_without_duplicating() {
677
    let mut app = test_app();
678
    assert_eq!(app.collection.active_server, 0);
679
    type_url(&mut app, "https://two.example.com/pets");
680
681
    assert_eq!(app.collection.servers.len(), 2);
682
    assert_eq!(app.collection.active_server, 1);
683
    assert_eq!(app.collection.requests[0].path, "/pets");
684
}
685
686
#[test]
687
fn pasting_without_a_query_keeps_existing_params() {
688
    let mut app = test_app();
689
    type_url(&mut app, "https://one.example.com/pets/all");
690
691
    let req = &app.collection.requests[0];
692
    assert_eq!(req.path, "/pets/all");
693
    assert_eq!(req.query.len(), 2);
694
    // Disabled optional params from the fixture survive untouched.
695
    assert!(req.query.iter().all(|r| !r.enabled));
696
    assert_eq!(req.query[0].key, "limit");
697
}
698
699
#[test]
700
fn non_http_scheme_is_rejected() {
701
    let mut app = test_app();
702
    type_url(&mut app, "ftp://files.example.com/pets");
703
704
    assert_eq!(app.collection.requests[0].path, "/pets");
705
    assert_eq!(app.collection.servers.len(), 2);
706
    assert!(app.status.contains("http(s)"));
707
}
708
709
#[test]
710
fn duplicate_request_clones_directly_after_the_original() {
711
    let mut app = test_app();
712
    handle_key(&mut app, char_key('1'));
713
    handle_key(&mut app, char_key('j')); // first request row (listPets)
714
    handle_key(&mut app, char_key('y'));
715
716
    assert_eq!(app.collection.requests.len(), 4);
717
    assert_eq!(app.collection.requests[1].name, "listPets copy");
718
    assert_ne!(app.collection.requests[0].id, app.collection.requests[1].id);
719
    assert_eq!(app.collection.requests[1].path, "/pets");
720
    assert_eq!(app.collection.requests[1].query.len(), 2);
721
    // The original is still in place, and the clone is what's open.
722
    assert_eq!(app.collection.requests[0].name, "listPets");
723
    assert_eq!(app.selected, Some(1));
724
    assert_eq!(app.focus, Focus::Sidebar);
725
    assert_eq!(app.sidebar_rows[app.sidebar_sel], SidebarRow::Request(1));
726
    assert!(app.dirty);
727
}
728
729
#[test]
730
fn duplicate_twice_numbers_the_copies() {
731
    let mut app = test_app();
732
    handle_key(&mut app, char_key('1'));
733
    handle_key(&mut app, char_key('j'));
734
    handle_key(&mut app, char_key('y'));
735
    handle_key(&mut app, char_key('y'));
736
737
    assert_eq!(app.collection.requests.len(), 5);
738
    let names: Vec<&str> = app
739
        .collection
740
        .requests
741
        .iter()
742
        .map(|r| r.name.as_str())
743
        .collect();
744
    // The cursor followed the first clone, so the second `y` duplicates *it* —
745
    // and the ` copy` suffix is stripped first, giving `copy 2` not `copy copy`.
746
    assert_eq!(names[1], "listPets copy");
747
    assert_eq!(names[2], "listPets copy 2");
748
}
749
750
#[test]
751
fn new_request_chains_into_the_url_prompt() {
752
    let mut app = test_app();
753
    handle_key(&mut app, char_key('1'));
754
    handle_key(&mut app, char_key('n'));
755
    type_str(&mut app, "adhoc");
756
    handle_key(&mut app, key(KeyCode::Enter));
757
758
    // The name commit leaves you in the URL prompt rather than on `GET /`.
759
    assert_eq!(app.editing, Some(EditTarget::Url));
760
    assert_eq!(app.mode, Mode::Insert);
761
    type_str(&mut app, "https://four.example.com/ip");
762
    handle_key(&mut app, key(KeyCode::Enter));
763
764
    let req = app.collection.requests.last().unwrap();
765
    assert_eq!(req.name, "adhoc");
766
    assert_eq!(req.path, "/ip");
767
    assert_eq!(app.collection.base_url(), Some("https://four.example.com"));
768
}
769
770
#[test]
771
fn switch_collection_replaces_state() {
772
    let mut app = test_app();
773
    handle_key(&mut app, char_key('/'));
774
    type_str(&mut app, "orders");
775
    handle_key(&mut app, key(KeyCode::Enter));
776
    handle_key(&mut app, char_key('2'));
777
    handle_key(&mut app, char_key('m')); // dirty it
778
779
    let mut other = Collection::new("other");
780
    other.requests = vec![SavedRequest::blank("only")];
781
    app.switch_collection(other, PathBuf::from("/tmp/cielago-other.json"));
782
783
    assert_eq!(app.collection.name, "other");
784
    assert_eq!(app.collection.requests.len(), 1);
785
    assert_eq!(app.selected, Some(0));
786
    assert!(!app.dirty);
787
    assert!(app.filter.is_empty());
788
    assert!(app.response.is_none());
789
    assert!(app.status.contains("other"));
790
    // Tracked in memory only — `switch_collection` must not write to the real
791
    // `~/.config/cielago`, which is where `store::config_dir` always points.
792
    assert_eq!(app.config.last_collection.as_deref(), Some("other"));
793
}
794
795
#[test]
796
fn new_collection_command_refuses_when_dirty() {
797
    let mut app = test_app();
798
    handle_key(&mut app, char_key('2'));
799
    handle_key(&mut app, char_key('m')); // cycle method → dirty
800
    assert!(app.dirty);
801
802
    // The dirty guard runs before any filesystem access, so this touches nothing.
803
    handle_key(&mut app, char_key(':'));
804
    type_str(&mut app, "new Scratch");
805
    handle_key(&mut app, key(KeyCode::Enter));
806
807
    assert_eq!(app.collection.name, "test");
808
    assert!(app.status.contains("Unsaved changes"));
809
}
810
811
#[test]
812
fn new_collection_command_rejects_a_missing_name() {
813
    let mut app = test_app();
814
    handle_key(&mut app, char_key(':'));
815
    type_str(&mut app, "new  ");
816
    handle_key(&mut app, key(KeyCode::Enter));
817
818
    assert_eq!(app.collection.name, "test");
819
    assert!(app.status.starts_with("Usage: :new"));
820
}