use std::path::PathBuf;
use std::sync::Arc;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::collection::Collection;
use crate::git_remote::{RefKind, RemoteRefs};
use crate::hurl::HurlEntry;
use crate::i18n::Language;
use crate::persistence::PersistedState;
use crate::request::RequestView;
use super::app::*;
use super::git_save::*;
use super::new_request::*;
use super::remote::*;
use super::wrapcache::TextPos;
fn app_in_main_pane() -> TuiApp {
let mut app = TuiApp::default();
let entry = HurlEntry {
method: "GET".to_string(),
url: "http://192.0.2.1:81/hang".to_string(),
..Default::default()
};
app.collections
.push(Collection::new("t".to_string(), vec![entry]));
app.active_tab = 1;
app.focus = Pane::Main;
app
}
#[test]
fn ctrl_enter_runs_request_instead_of_editing() {
let mut app = app_in_main_pane();
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(
app.overlay.is_none(),
"Ctrl+Enter must not open the JSON editor"
);
assert!(
app.response.lock().unwrap().loading,
"Ctrl+Enter must start the request"
);
}
#[test]
fn plain_enter_opens_editor() {
let mut app = app_in_main_pane();
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(
app.overlay.is_some(),
"Enter should open the Edit Request wizard"
);
assert!(!app.response.lock().unwrap().loading);
}
#[test]
fn f5_runs_request_on_any_terminal() {
let mut app = app_in_main_pane();
app.on_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
assert!(
app.response.lock().unwrap().loading,
"F5 must start the request"
);
}
#[test]
fn main_scroll_is_clamped_to_content() {
let mut app = app_in_main_pane();
app.main_max_scroll = 3;
for _ in 0..10 {
app.nav(1);
}
assert_eq!(app.main_scroll, 3, "must not scroll past the last line");
for _ in 0..10 {
app.nav(-1);
}
assert_eq!(app.main_scroll, 0, "must not scroll above the first line");
}
#[test]
fn resp_scroll_is_clamped_to_content() {
let mut app = app_in_main_pane();
app.focus = Pane::Response;
app.resp_max_scroll = 3;
for _ in 0..10 {
app.nav(1);
}
assert_eq!(app.resp_scroll, 3, "must not scroll past the last line");
for _ in 0..10 {
app.nav(-1);
}
assert_eq!(app.resp_scroll, 0, "must not scroll above the first line");
}
fn press(app: &mut TuiApp, code: KeyCode) {
app.on_key(KeyEvent::new(code, KeyModifiers::NONE));
}
fn add_global_env(app: &mut TuiApp, env: crate::environment::Environment) -> u64 {
let id = env.id;
app.global_envs.push(env);
id
}
fn add_empty_global_env(app: &mut TuiApp, name: &str) -> u64 {
let (mut env, _) = crate::environment::parse_vars_pending(name.into(), "PLACEHOLDER=1");
env.vars.clear();
add_global_env(app, env)
}
fn only_env_id(app: &TuiApp) -> u64 {
app.global_envs[0].id
}
fn only_env(app: &TuiApp) -> &crate::environment::Environment {
&app.global_envs[0]
}
fn only_env_mut(app: &mut TuiApp) -> &mut crate::environment::Environment {
&mut app.global_envs[0]
}
fn open_only_env_popup(app: &mut TuiApp) {
app.overlay = Some(Overlay::EnvPopup(EnvPopupState::new(only_env_id(app))));
}
#[test]
fn effective_env_prefers_the_linked_environment_on_a_key_collision() {
let mut app = TuiApp::default();
let (active, _) =
crate::environment::parse_vars_pending("global".into(), "TOKEN=from-global\nONLY_GLOBAL=g");
let active_id = add_global_env(&mut app, active);
app.active_env_id = Some(active_id);
let (linked, _) =
crate::environment::parse_vars_pending("linked".into(), "TOKEN=from-linked\nONLY_LINKED=l");
let linked_id = add_global_env(&mut app, linked);
app.collections[0].linked_env_id = Some(linked_id);
let merged = app
.effective_env(0)
.expect("both a linked and active env are set");
let token = merged.vars.iter().find(|v| v.key == "TOKEN").unwrap();
assert_eq!(
token.value, "from-linked",
"the linked environment must win on a key collision"
);
assert!(
merged.vars.iter().any(|v| v.key == "ONLY_GLOBAL"),
"non-colliding global vars are kept"
);
assert!(
merged.vars.iter().any(|v| v.key == "ONLY_LINKED"),
"non-colliding linked vars are kept"
);
}
#[test]
fn shadowed_env_keys_reports_only_keys_defined_in_both_environments() {
let mut app = TuiApp::default();
let (active, _) =
crate::environment::parse_vars_pending("global".into(), "TOKEN=from-global\nONLY_GLOBAL=g");
let active_id = add_global_env(&mut app, active);
app.active_env_id = Some(active_id);
let (linked, _) =
crate::environment::parse_vars_pending("linked".into(), "TOKEN=from-linked\nONLY_LINKED=l");
let linked_id = add_global_env(&mut app, linked);
app.collections[0].linked_env_id = Some(linked_id);
let shadowed = app.shadowed_env_keys(0);
assert!(
shadowed.contains("TOKEN"),
"a key defined in both must be reported as shadowed"
);
assert!(
!shadowed.contains("ONLY_GLOBAL"),
"a key only in the active global env is not shadowed"
);
assert!(
!shadowed.contains("ONLY_LINKED"),
"a key only in the linked env is not shadowed"
);
}
#[test]
fn shadowed_env_keys_is_empty_without_both_a_linked_and_an_active_environment() {
let mut app = TuiApp::default();
let (active, _) = crate::environment::parse_vars_pending("global".into(), "TOKEN=from-global");
let active_id = add_global_env(&mut app, active);
app.active_env_id = Some(active_id);
assert!(app.shadowed_env_keys(0).is_empty());
}
#[test]
fn f2_on_the_environments_panel_renames_the_selected_environment() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("staging".into(), "TOKEN=v");
let env_id = add_global_env(&mut app, env);
app.focus = Pane::GlobalEnv;
app.global_env_idx = 0;
press(&mut app, KeyCode::F(2));
match &app.overlay {
Some(Overlay::Prompt { kind, editor, .. }) => {
assert!(
matches!(kind, PromptKind::RenameEnv(id) if *id == env_id),
"F2 on the env panel opens the environment rename prompt, not the tab rename"
);
assert_eq!(editor.text(), "staging", "prefilled with the current name");
}
_ => panic!("F2 did not open a rename prompt"),
}
}
#[test]
fn shadowed_env_keys_is_empty_when_linked_env_is_also_the_active_env() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("shared".into(), "TOKEN=v\nOTHER=w");
let id = add_global_env(&mut app, env);
app.active_env_id = Some(id);
app.collections[0].linked_env_id = Some(id);
assert!(app.shadowed_env_keys(0).is_empty());
}
#[test]
fn creating_a_request_adds_it_to_the_request_tab() {
let mut app = TuiApp::default();
assert!(
app.collections[0].entries.is_empty(),
"Request tab starts empty"
);
press(&mut app, KeyCode::Char('n')); assert!(app.overlay.is_some());
for ch in "demo".chars() {
press(&mut app, KeyCode::Char(ch)); }
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.overlay.is_none(), "form closes after create");
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(e[0].title, "demo");
assert_eq!(e[0].method, "POST");
assert_eq!(e[0].url, "http://h/x");
}
#[test]
fn new_request_can_target_another_collection() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(
app.collections[0].entries.is_empty(),
"Request tab stays empty"
);
assert_eq!(
app.collections[1].entries.len(),
1,
"request added to the chosen collection"
);
assert_eq!(
app.active_tab, 1,
"focus follows the request to its collection"
);
}
#[test]
fn creating_a_request_with_a_header_via_the_table() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "X-Test".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); for ch in "abc".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(
e[0].headers,
vec![("X-Test".to_string(), "abc".to_string())]
);
}
#[test]
fn empty_header_rows_are_not_added() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n')); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert!(e[0].headers.is_empty(), "blank header rows must be dropped");
}
#[test]
fn header_widths_drop_description_first_when_narrow() {
let (e, k, v, d) = header_widths(80);
assert!(e > 0 && k > 0 && v > 0 && d > 0);
assert_eq!(e + k + v + d + 3, 80, "columns plus gaps fill the width");
let (e, k, v, d) = header_widths(30);
assert!(e > 0 && k > 0 && v > 0);
assert_eq!(d, 0, "Description is the first column to lose its width");
}
fn new_focus(app: &TuiApp) -> NewField {
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => f.focus,
_ => panic!("New Request overlay not open"),
}
}
fn form_ref(app: &TuiApp) -> &NewReq {
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => f,
_ => panic!("New Request overlay not open"),
}
}
fn form_step(app: &mut TuiApp, forward: bool) -> NewField {
match app.overlay.as_mut().unwrap() {
Overlay::NewRequest(f) => {
f.focus_next(forward);
f.focus
}
_ => panic!("New Request overlay not open"),
}
}
#[test]
fn tab_ring_backward_exactly_reverses_forward() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::Char('X'));
let start = new_focus(&app);
let mut fwd = vec![start];
loop {
let f = form_step(&mut app, true);
if f == start {
break;
}
fwd.push(f);
assert!(fwd.len() < 200, "forward ring never returned to start");
}
for &expected in fwd.iter().skip(1).rev() {
assert_eq!(form_step(&mut app, false), expected);
}
assert_eq!(form_step(&mut app, false), start);
}
#[test]
fn tab_skips_empty_headers_cookies_and_form_between_url_and_body() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddCookie);
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddFormField);
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::Body);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::AddFormField);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::AddCookie);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::AddHeader);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::Url);
}
fn header_enabled(app: &TuiApp, i: usize) -> bool {
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => f.headers[i].enabled,
_ => panic!("New Request overlay not open"),
}
}
fn open_form_on_header(app: &mut TuiApp) {
press(app, KeyCode::Char('n'));
press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Enter); }
fn open_form_on_form_field_kind(app: &mut TuiApp) {
press(app, KeyCode::Char('n'));
press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Tab); press(app, KeyCode::Enter); press(app, KeyCode::Char('k')); press(app, KeyCode::Tab); }
#[test]
fn deleting_the_last_header_leaves_the_section_empty() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
app.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => assert!(
f.headers.is_empty(),
"the section is left empty, not re-seeded"
),
_ => panic!("form not open"),
}
assert_eq!(
new_focus(&app),
NewField::AddHeader,
"focus lands on the Add row"
);
press(&mut app, KeyCode::Enter);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
press(&mut app, KeyCode::Char('X'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
}
#[test]
fn arrow_keys_move_between_columns_in_a_header_row() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
press(&mut app, KeyCode::Right);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Right);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Desc));
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Desc));
press(&mut app, KeyCode::Left);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Left);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
press(&mut app, KeyCode::Left);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Enabled));
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Enabled));
}
#[test]
fn left_right_move_the_cursor_before_crossing_cells() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
for ch in "ab".chars() {
press(&mut app, KeyCode::Char(ch)); }
press(&mut app, KeyCode::Right);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Left);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
}
fn focused_cell_col(app: &TuiApp) -> usize {
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => match f.focus {
NewField::Header(i, col) => {
let row = &f.headers[i];
match col {
HdrCol::Key => row.key.col,
HdrCol::Value => row.value.col,
HdrCol::Desc => row.desc.col,
HdrCol::Enabled => usize::MAX,
}
}
_ => panic!("focus is not on a header cell"),
},
_ => panic!("New Request overlay not open"),
}
}
#[test]
fn ctrl_left_right_jump_to_start_and_end_of_cell() {
let mut app = TuiApp::default();
open_form_on_header(&mut app); for ch in "hello".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert_eq!(focused_cell_col(&app), 5, "cursor at end after typing");
app.on_key(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"stays in the same cell"
);
assert_eq!(focused_cell_col(&app), 0, "Ctrl+Left goes to the start");
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"stays in the same cell"
);
assert_eq!(focused_cell_col(&app), 5, "Ctrl+Right goes to the end");
}
#[test]
fn up_down_move_between_header_rows() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::Char('k')); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
press(&mut app, KeyCode::Char(' ')); assert_eq!(new_focus(&app), NewField::Header(1, HdrCol::Key));
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::Header(1, HdrCol::Value));
press(&mut app, KeyCode::Up);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Up); assert_eq!(new_focus(&app), NewField::Url);
press(&mut app, KeyCode::Down); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Down); assert_eq!(new_focus(&app), NewField::Header(1, HdrCol::Value));
press(&mut app, KeyCode::Down); assert_eq!(new_focus(&app), NewField::AddHeader);
}
#[test]
fn up_down_navigate_between_form_sections() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n')); assert_eq!(new_focus(&app), NewField::Name);
press(&mut app, KeyCode::Down);
assert_eq!(new_focus(&app), NewField::Target);
press(&mut app, KeyCode::Down);
assert_eq!(new_focus(&app), NewField::Method);
press(&mut app, KeyCode::Down);
assert_eq!(new_focus(&app), NewField::Url);
press(&mut app, KeyCode::Up);
assert_eq!(new_focus(&app), NewField::Method);
press(&mut app, KeyCode::Up);
assert_eq!(new_focus(&app), NewField::Target);
press(&mut app, KeyCode::Up);
assert_eq!(new_focus(&app), NewField::Name);
}
#[test]
fn arrow_up_stops_at_every_section_it_passes_through_just_like_arrow_down_does() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::AddHeader,
"Up from the first Cookie row must stop at the empty Headers section's Add row, not skip past it to Url"
);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::Url,
"the next Up leaves Headers upward as normal"
);
}
#[test]
fn arrow_up_from_the_first_form_field_stops_at_cookies_then_headers_one_section_at_a_time() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::AddCookie,
"Up from the first Form row must stop at the empty Cookies section first"
);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::AddHeader,
"the next Up stops at the empty Headers section, not before"
);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::Url,
"the next Up leaves Headers upward as normal"
);
}
#[test]
fn arrow_up_from_the_first_capture_row_stops_at_the_empty_asserts_section() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
for _ in 0..7 {
press(&mut app, KeyCode::Tab); }
assert_eq!(new_focus(&app), NewField::Body);
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::AddAssert,
"Up from the first Capture row must stop at the empty Asserts section's Add row, not skip past it to Body"
);
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::Body,
"the next Up leaves Asserts upward as normal"
);
}
#[test]
fn up_down_in_the_body_move_the_cursor_then_leave_at_the_edges() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
for _ in 0..7 {
press(&mut app, KeyCode::Tab); }
assert_eq!(new_focus(&app), NewField::Body);
for ch in "line1".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Enter); for ch in "line2".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Up);
assert_eq!(
new_focus(&app),
NewField::Body,
"Up within a multi-line body moves the cursor"
);
press(&mut app, KeyCode::Up);
assert_ne!(
new_focus(&app),
NewField::Body,
"Up at the top of the body leaves the section"
);
}
#[test]
fn filter_headers_matches_case_insensitive_substring() {
assert_eq!(filter_headers(""), COMMON_HEADERS.to_vec());
assert_eq!(filter_headers("auth"), vec!["Authorization"]);
let content = filter_headers("Content");
assert!(content.contains(&"Content-Type"));
assert!(
content
.iter()
.all(|h| h.to_ascii_lowercase().contains("content"))
);
assert!(filter_headers("zzz").is_empty());
}
#[test]
fn key_cell_shows_a_prepopulated_dropdown() {
let mut app = TuiApp::default();
open_form_on_header(&mut app); let dd = form_ref(&app).key_dropdown();
assert!(
dd.is_some(),
"an empty Key cell offers the full suggestion list"
);
assert_eq!(dd.unwrap().1, COMMON_HEADERS.to_vec());
}
#[test]
fn dropdown_filters_as_you_type_and_enter_fills_the_key() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
for ch in "content".chars() {
press(&mut app, KeyCode::Char(ch));
}
let sugs = form_ref(&app)
.key_dropdown()
.expect("matches for 'content'")
.1;
assert!(
sugs.iter()
.all(|h| h.to_ascii_lowercase().contains("content"))
);
press(&mut app, KeyCode::Down);
assert_eq!(form_ref(&app).suggest_hi, Some(0));
let first = sugs[0];
press(&mut app, KeyCode::Enter);
assert_eq!(
form_ref(&app).headers[0].key.text(),
first,
"Enter fills the Key"
);
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Value),
"focus advances"
);
assert!(
form_ref(&app).key_dropdown().is_none(),
"dropdown closed after accept"
);
}
#[test]
fn down_on_key_cell_navigates_suggestions_not_rows() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::Down);
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"focus stays on the Key cell"
);
assert_eq!(
form_ref(&app).suggest_hi,
Some(0),
"the dropdown highlight advances"
);
}
#[test]
fn esc_dismisses_dropdown_before_cancelling_the_form() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
assert!(form_ref(&app).key_dropdown().is_some());
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_some(), "the form stays open");
assert!(
form_ref(&app).key_dropdown().is_none(),
"the dropdown is dismissed"
);
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_none(), "the form is cancelled");
}
#[test]
fn typing_no_match_hides_the_dropdown() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
for ch in "zzz".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert!(
form_ref(&app).key_dropdown().is_none(),
"no header contains 'zzz'"
);
}
#[test]
fn tab_skips_the_enabled_checkbox() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::Char('k'));
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Desc));
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Desc));
}
#[test]
fn arrows_can_still_reach_the_enabled_checkbox() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Enabled));
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Key));
}
#[test]
fn ctrl_e_toggles_enabled_without_moving_focus() {
let mut app = TuiApp::default();
open_form_on_header(&mut app); assert!(header_enabled(&app, 0), "rows start enabled");
app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(
!header_enabled(&app, 0),
"Ctrl+E disables the focused header"
);
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"Ctrl+E toggles in place, without jumping focus to the checkbox"
);
app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(header_enabled(&app, 0), "Ctrl+E re-enables it");
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"focus still hasn't moved"
);
}
#[test]
fn arrows_can_reach_the_enabled_checkbox_in_a_form_row() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Key));
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Enabled));
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Enabled));
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Key));
}
#[test]
fn ctrl_e_toggles_enabled_on_a_form_row_without_moving_focus() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); assert!(form_ref(&app).form_fields[0].enabled, "rows start enabled");
app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert!(
!form_ref(&app).form_fields[0].enabled,
"Ctrl+E disables the focused row"
);
assert_eq!(
new_focus(&app),
NewField::FormField(0, FormCol::Kind),
"Ctrl+E toggles in place, without jumping focus to the checkbox"
);
}
#[test]
fn disabled_header_is_not_sent() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "X-Test".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); for ch in "abc".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL));
assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
assert!(!header_enabled(&app, 0), "the row is now disabled");
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert!(
e[0].headers.is_empty(),
"a disabled header must not be sent"
);
}
#[test]
fn right_arrow_fills_base_url_ghost() {
let mut app = TuiApp::default();
app.vars.base_url = "http://base.example".to_string();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Right); app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(e[0].url, "http://base.example");
}
#[test]
fn default_request_view_setting_round_trips() {
let mut app = TuiApp::default();
assert_eq!(
app.default_request_view,
RequestView::Json,
"defaults to JSON"
);
app.default_request_view = RequestView::Hurl;
let snapshot = app.to_persisted();
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(
restored.default_request_view,
RequestView::Hurl,
"the view preference survives a round trip"
);
let json = serde_json::to_string(&restored.to_persisted()).unwrap();
let back: PersistedState = serde_json::from_str(&json).unwrap();
let mut restored2 = TuiApp::default();
restored2.apply_persisted(back);
assert_eq!(
restored2.default_request_view,
RequestView::Hurl,
"the view preference survives JSON (de)serialization"
);
}
#[test]
fn persisted_state_round_trips_requests_and_settings() {
let mut app = TuiApp::default();
app.vars.base_url = "http://example.test".to_string();
app.language = Language::French;
app.collections[0].entries.push(HurlEntry::from_fields(
"hc",
"GET",
"http://h/health",
vec![],
"",
));
app.collections[0].entries.push(HurlEntry::from_fields(
"post",
"POST",
"http://h/x",
vec![("X-A".into(), "1".into())],
"{}",
));
let snapshot = app.to_persisted();
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(restored.vars.base_url, "http://example.test");
assert_eq!(restored.language, Language::French);
assert_eq!(restored.collections.len(), 1);
let e = &restored.collections[0].entries;
assert_eq!(e.len(), 2);
assert_eq!(e[0].url, "http://h/health");
assert_eq!(e[1].method, "POST");
assert_eq!(e[1].headers, vec![("X-A".to_string(), "1".to_string())]);
}
#[test]
fn persisted_state_survives_json_serialization() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
let json = serde_json::to_string(&app.to_persisted()).unwrap();
let back: PersistedState = serde_json::from_str(&json).unwrap();
let mut restored = TuiApp::default();
restored.apply_persisted(back);
assert_eq!(restored.collections[0].entries.len(), 1);
assert_eq!(restored.collections[0].entries[0].url, "http://h/x");
}
#[test]
fn active_tab_divider_positions_and_recent_git_urls_round_trip() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.list_width = 50;
app.response_pct = 60;
app.recent_git_urls = vec![
"https://example.test/repo.git".into(),
"https://example.test/other.git".into(),
];
let snapshot = app.to_persisted();
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(restored.active_tab, 1, "the active tab is restored");
assert_eq!(restored.list_width, 50, "the left column width is restored");
assert_eq!(
restored.response_pct, 60,
"the response pane height is restored"
);
assert_eq!(
restored.recent_git_urls,
vec![
"https://example.test/repo.git".to_string(),
"https://example.test/other.git".to_string()
],
"recent git urls are restored, most recent first"
);
}
#[test]
fn plain_state_json_without_divider_fields_uses_defaults() {
let json = r#"{"language":"English","base_url":"","tabs":[]}"#;
let state: PersistedState = serde_json::from_str(json).unwrap();
assert_eq!(state.list_width, 38);
assert_eq!(state.response_pct, 42);
assert!(state.recent_git_urls.is_empty());
assert_eq!(state.active_tab, 0);
}
#[test]
fn gt_and_lt_resize_the_left_column_and_persist() {
let mut app = TuiApp::default();
let start = app.list_width;
press(&mut app, KeyCode::Char('>'));
assert_eq!(app.list_width, start + 2, "> grows the left column");
press(&mut app, KeyCode::Char('<'));
press(&mut app, KeyCode::Char('<'));
assert_eq!(app.list_width, start - 2, "< shrinks the left column");
}
#[test]
fn minus_grows_and_plus_shrinks_the_response_pane() {
let mut app = TuiApp::default();
let start = app.response_pct;
press(&mut app, KeyCode::Char('-'));
assert_eq!(app.response_pct, start + 5, "- grows the response pane");
press(&mut app, KeyCode::Char('+'));
press(&mut app, KeyCode::Char('+'));
assert_eq!(app.response_pct, start - 5, "+ shrinks the response pane");
}
#[test]
fn clear_all_resets_to_a_single_empty_request_tab() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
app.collections
.push(Collection::new("extra".into(), vec![]));
app.active_tab = 1;
app.vars.base_url = "http://changed".into();
app.clear_all();
assert_eq!(app.collections.len(), 1);
assert_eq!(app.collections[0].name, "Request");
assert!(app.collections[0].entries.is_empty());
assert_eq!(app.active_tab, 0);
assert_eq!(app.vars.base_url, "http://127.0.0.1:8080");
}
#[test]
fn clearing_via_the_options_menu_asks_for_confirmation() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
press(&mut app, KeyCode::Char('s')); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert!(
matches!(
app.overlay,
Some(Overlay::Confirm {
action: ConfirmAction::Clear,
..
})
),
"a confirmation popup appears before clearing"
);
assert!(
!app.collections[0].entries.is_empty(),
"nothing is cleared yet"
);
press(&mut app, KeyCode::Char('y')); assert!(
app.collections[0].entries.is_empty(),
"requests are cleared after confirming"
);
assert!(app.overlay.is_none(), "the popup closes");
assert!(app.status.is_some(), "a status is shown");
}
#[test]
fn declining_the_clear_confirmation_keeps_requests() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
press(&mut app, KeyCode::Char('s'));
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Char('n'));
assert!(app.overlay.is_none(), "the popup closes");
assert!(!app.collections[0].entries.is_empty(), "requests are kept");
}
#[test]
fn clearing_is_immediate_when_confirmation_is_disabled() {
let mut app = TuiApp {
confirm_on_clear: false,
..Default::default()
};
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
press(&mut app, KeyCode::Char('s'));
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none(), "no confirmation popup");
assert!(
app.collections[0].entries.is_empty(),
"requests are cleared immediately"
);
}
#[test]
fn pressing_q_asks_before_quitting() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('q'));
assert!(!app.quit, "q does not quit immediately");
assert!(
matches!(
app.overlay,
Some(Overlay::Confirm {
action: ConfirmAction::Exit,
..
})
),
"a quit confirmation popup appears"
);
press(&mut app, KeyCode::Char('y')); assert!(app.quit, "confirming quits");
}
#[test]
fn declining_the_quit_confirmation_stays_open() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('q'));
press(&mut app, KeyCode::Char('n'));
assert!(!app.quit, "declining keeps the app open");
assert!(app.overlay.is_none(), "the popup closes");
}
#[test]
fn q_quits_immediately_when_confirmation_is_disabled() {
let mut app = TuiApp {
confirm_on_exit: false,
..Default::default()
};
press(&mut app, KeyCode::Char('q'));
assert!(app.quit, "q quits directly when confirmation is off");
}
#[test]
fn preferences_menu_toggles_confirmation_flags() {
let mut app = TuiApp::default();
assert!(
app.confirm_on_exit && app.confirm_on_clear,
"both default to true"
);
press(&mut app, KeyCode::Char('s')); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Enter);
assert!(!app.confirm_on_exit, "confirm-on-exit toggled off");
assert!(app.confirm_on_clear, "confirm-on-clear unchanged");
assert!(
matches!(app.overlay, Some(Overlay::Preferences(0))),
"still in the Preferences menu"
);
}
#[test]
fn preferences_menu_third_item_opens_a_default_request_view_submenu() {
let mut app = TuiApp::default();
assert_eq!(
app.default_request_view,
RequestView::Json,
"defaults to JSON"
);
press(&mut app, KeyCode::Char('s')); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down); assert!(
matches!(app.overlay, Some(Overlay::Preferences(2))),
"Down moves to the 3rd item without wrapping past it"
);
press(&mut app, KeyCode::Enter); assert!(
matches!(app.overlay, Some(Overlay::RequestViewMenu(0))),
"opens a submenu preselecting the current view (JSON = 0)"
);
assert_eq!(
app.default_request_view,
RequestView::Json,
"opening the submenu doesn't change anything yet"
);
press(&mut app, KeyCode::Down); assert_eq!(
app.default_request_view,
RequestView::Hurl,
"hovering over Hurl previews it immediately"
);
press(&mut app, KeyCode::Enter); assert_eq!(
app.default_request_view,
RequestView::Hurl,
"selecting Hurl in the submenu sets the view"
);
assert!(
matches!(app.overlay, Some(Overlay::Preferences(2))),
"Enter returns to Preferences instead of closing the whole menu"
);
assert!(app.confirm_on_exit, "unrelated settings are untouched");
assert!(app.confirm_on_clear, "unrelated settings are untouched");
press(&mut app, KeyCode::Enter); assert!(
matches!(app.overlay, Some(Overlay::RequestViewMenu(1))),
"preselects Hurl (index 1)"
);
press(&mut app, KeyCode::Esc);
assert!(
matches!(app.overlay, Some(Overlay::Preferences(2))),
"Esc backs out to Preferences"
);
assert_eq!(
app.default_request_view,
RequestView::Hurl,
"Esc doesn't change the setting"
);
}
#[test]
fn hovering_up_and_down_in_the_request_view_submenu_previews_it_live() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('s'));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); assert_eq!(app.default_request_view, RequestView::Json);
press(&mut app, KeyCode::Down); assert_eq!(
app.default_request_view,
RequestView::Hurl,
"hovering onto Hurl previews it immediately"
);
press(&mut app, KeyCode::Up); assert_eq!(
app.default_request_view,
RequestView::Json,
"hovering back onto JSON restores it immediately"
);
press(&mut app, KeyCode::Enter);
assert!(matches!(app.overlay, Some(Overlay::Preferences(2))));
assert_eq!(app.default_request_view, RequestView::Json);
}
#[test]
fn settings_survive_closing_all_collections() {
let mut app = TuiApp {
confirm_on_exit: false,
confirm_on_clear: false,
language: Language::French,
..Default::default()
};
app.clear_all();
assert!(!app.confirm_on_exit, "confirm-on-exit setting is preserved");
assert!(
!app.confirm_on_clear,
"confirm-on-clear setting is preserved"
);
assert_eq!(app.language, Language::French, "language is preserved");
}
#[test]
fn language_is_chosen_from_a_submenu() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('s')); assert!(matches!(app.overlay, Some(Overlay::Options(0))));
press(&mut app, KeyCode::Enter); assert!(matches!(app.overlay, Some(Overlay::LanguageMenu(_))));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); assert_eq!(app.language, Language::French);
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("paperboy_{tag}_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn browser_reopens_in_the_last_used_folder() {
let dir = temp_dir("reopen");
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
app.open_browser(FileAction::LoadEnv);
match app.overlay {
Some(Overlay::Browser(_, ex)) => assert_eq!(ex.cwd(), &dir),
_ => panic!("browser overlay not open"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn browser_without_a_remembered_folder_opens_normally() {
let mut app = TuiApp::default();
app.open_browser(FileAction::OpenCollection);
assert!(
matches!(app.overlay, Some(Overlay::Browser(..))),
"browser still opens"
);
}
#[test]
fn selecting_a_file_remembers_its_folder() {
let dir = temp_dir("select");
std::fs::write(dir.join("staging.vars"), "A=1\n").unwrap();
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
app.open_browser(FileAction::LoadEnv);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert_eq!(
app.last_browse_dir.as_ref(),
Some(&dir),
"remembers the file's folder"
);
assert!(app.overlay.is_none(), "browser closed after selecting");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn last_browse_dir_survives_persistence() {
let app = TuiApp {
last_browse_dir: Some(PathBuf::from("/some/dir")),
..Default::default()
};
let snapshot = app.to_persisted();
assert_eq!(snapshot.last_browse_dir.as_deref(), Some("/some/dir"));
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(restored.last_browse_dir, Some(PathBuf::from("/some/dir")));
}
#[test]
fn env_picker_prefers_the_last_environment_folder() {
let env_dir = temp_dir("envfolder");
let other_dir = temp_dir("otherfolder");
let mut app = TuiApp {
last_browse_dir: Some(other_dir.clone()),
last_env_dir: Some(env_dir.clone()),
..Default::default()
};
app.open_browser(FileAction::LoadEnv);
match app.overlay {
Some(Overlay::Browser(_, ex)) => assert_eq!(ex.cwd(), &env_dir),
_ => panic!("browser overlay not open"),
}
std::fs::remove_dir_all(&env_dir).ok();
std::fs::remove_dir_all(&other_dir).ok();
}
#[test]
fn going_up_highlights_the_folder_just_left_so_right_returns() {
let dir = temp_dir("upreturn");
let sub = dir.join("nested");
std::fs::create_dir_all(&sub).unwrap();
let mut app = TuiApp {
last_browse_dir: Some(sub.clone()),
..Default::default()
};
app.open_browser(FileAction::OpenCollection);
press(&mut app, KeyCode::Left);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &dir, "moved up one level");
assert_eq!(ex.current().path, sub, "the folder we left is highlighted");
}
_ => panic!("browser overlay not open"),
}
press(&mut app, KeyCode::Right);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &sub, "Right returns into the folder we left")
}
_ => panic!("browser overlay not open"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn many_lefts_then_many_rights_return_to_the_starting_folder() {
let a = temp_dir("trail");
let b = a.join("b");
let c = b.join("c");
let d = c.join("d");
std::fs::create_dir_all(&d).unwrap();
let mut app = TuiApp {
last_browse_dir: Some(d.clone()),
..Default::default()
};
app.open_browser(FileAction::OpenCollection);
press(&mut app, KeyCode::Left);
press(&mut app, KeyCode::Left);
press(&mut app, KeyCode::Left);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &a, "climbed to the top of the trail");
assert_eq!(ex.current().path, b, "the trail's next step is highlighted");
}
_ => panic!("browser overlay not open"),
}
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Right);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &d, "retraced all the way back to the start")
}
_ => panic!("browser overlay not open"),
}
assert!(app.browser_forward_path.is_none());
std::fs::remove_dir_all(&a).ok();
}
#[test]
fn descending_into_a_different_folder_clears_the_retrace_trail() {
let a = temp_dir("clearsibling");
let c = a.join("b").join("c");
let z = a.join("z");
std::fs::create_dir_all(&c).unwrap();
std::fs::create_dir_all(&z).unwrap();
let mut app = TuiApp {
last_browse_dir: Some(a.join("b")),
..Default::default()
};
app.open_browser(FileAction::OpenCollection); press(&mut app, KeyCode::Left); assert!(app.browser_forward_path.is_some());
let idx = match &app.overlay {
Some(Overlay::Browser(_, ex)) => ex
.files()
.iter()
.position(|f| f.path == z)
.expect("z is listed"),
_ => panic!("browser overlay not open"),
};
match &mut app.overlay {
Some(Overlay::Browser(_, ex)) => ex.set_selected_idx(idx),
_ => unreachable!(),
}
press(&mut app, KeyCode::Right);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => assert_eq!(ex.cwd(), &z, "descended into the sibling"),
_ => panic!("browser overlay not open"),
}
assert!(
app.browser_forward_path.is_none(),
"a new navigation clears the retrace trail"
);
std::fs::remove_dir_all(&a).ok();
}
#[test]
fn right_does_not_ascend_through_the_parent_row_but_enter_still_does() {
let a = temp_dir("parentrow");
let b = a.join("b");
std::fs::create_dir_all(&b).unwrap();
let mut app = TuiApp {
last_browse_dir: Some(b.clone()),
..Default::default()
};
app.open_browser(FileAction::OpenCollection);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.current().path, a, "the '../' row points at the parent")
}
_ => panic!("browser overlay not open"),
}
press(&mut app, KeyCode::Right);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &b, "Right on '../' is a no-op — stays put")
}
_ => panic!("browser overlay not open"),
}
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_eq!(ex.cwd(), &a, "Enter on '../' ascends");
assert_eq!(ex.current().path, b, "and highlights the folder we left");
}
_ => panic!("browser overlay not open"),
}
std::fs::remove_dir_all(&a).ok();
}
#[test]
fn ctrl_r_resets_the_browser_to_the_folder_it_opened_in() {
let dir = temp_dir("resetorigin");
let sub = dir.join("nested");
std::fs::create_dir_all(&sub).unwrap();
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
app.open_browser(FileAction::OpenCollection); assert_eq!(app.browser_origin_dir.as_ref(), Some(&dir));
press(&mut app, KeyCode::Left);
match &app.overlay {
Some(Overlay::Browser(_, ex)) => {
assert_ne!(ex.cwd(), &dir, "navigated off the opening folder")
}
_ => panic!("browser overlay not open"),
}
app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
match &app.overlay {
Some(Overlay::Browser(_, ex)) => assert_eq!(ex.cwd(), &dir, "reset to the opening folder"),
_ => panic!("browser overlay not open"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn selecting_an_env_file_updates_the_env_folder() {
let dir = temp_dir("selectenv");
std::fs::write(dir.join("staging.vars"), "A=1\n").unwrap();
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
app.open_browser(FileAction::LoadEnv);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert_eq!(
app.last_env_dir.as_ref(),
Some(&dir),
"loading an environment records its folder for the env picker"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn last_env_dir_survives_persistence() {
let app = TuiApp {
last_env_dir: Some(PathBuf::from("/env/dir")),
..Default::default()
};
let snapshot = app.to_persisted();
assert_eq!(snapshot.last_env_dir.as_deref(), Some("/env/dir"));
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(restored.last_env_dir, Some(PathBuf::from("/env/dir")));
}
#[test]
fn loading_an_env_file_as_a_collection_is_rejected() {
let dir = temp_dir("wrongcol");
let env = dir.join("staging.vars");
std::fs::write(&env, "TOKEN=abc\nBASE=http://x\n").unwrap();
let mut app = TuiApp::default();
let before = app.collections.len();
app.do_file_action(FileAction::OpenCollection, env.to_str().unwrap());
assert_eq!(
app.collections.len(),
before,
"no tab added for a non-collection file"
);
assert!(
matches!(&app.status, Some(st) if !st.is_ok()),
"an error is shown"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn loading_a_collection_file_as_an_environment_is_rejected() {
let dir = temp_dir("wrongenv");
let col = dir.join("demo.hurl");
std::fs::write(
&col,
"# Health\nGET http://127.0.0.1:8080/health\nHTTP 200\n",
)
.unwrap();
let mut app = TuiApp::default();
app.do_file_action(FileAction::LoadEnv, col.to_str().unwrap());
assert!(app.global_envs.is_empty(), "no environment loaded");
assert!(
matches!(&app.status, Some(st) if !st.is_ok()),
"an error is shown"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn loading_a_valid_collection_adds_a_tab() {
let dir = temp_dir("goodcol");
let col = dir.join("demo.hurl");
std::fs::write(
&col,
"# Health\nGET http://127.0.0.1:8080/health\nHTTP 200\n",
)
.unwrap();
let mut app = TuiApp::default();
let before = app.collections.len();
app.do_file_action(FileAction::OpenCollection, col.to_str().unwrap());
assert_eq!(
app.collections.len(),
before + 1,
"a valid collection adds a tab"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn file_menu_top_level_has_load_and_save_submenus() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
assert!(matches!(app.overlay, Some(Overlay::FileMenu(0))));
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::FileLoadMenu(0))),
"Enter on \"(L)oad\" opens the Load submenu"
);
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Down);
assert!(matches!(app.overlay, Some(Overlay::FileMenu(1))));
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::FileSaveMenu(0))),
"Enter on \"(S)ave\" opens the Save submenu"
);
}
#[test]
fn file_menu_mnemonic_keys_jump_straight_into_a_submenu() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Char('s')); assert!(matches!(app.overlay, Some(Overlay::FileSaveMenu(0))));
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Char('L')); assert!(matches!(app.overlay, Some(Overlay::FileLoadMenu(0))));
}
#[test]
fn file_load_submenu_mnemonic_activates_the_item_immediately() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Char('c')); assert!(
matches!(
&app.overlay,
Some(Overlay::FileLoadSource(FileKind::Collection, 0))
),
"picking a kind opens the local-vs-git source step"
);
press(&mut app, KeyCode::Char('g')); assert!(
matches!(&app.overlay, Some(Overlay::RemoteGit(w)) if w.kind == RemoteKind::Collection),
"the git source both selects and activates without needing Enter"
);
}
#[test]
fn file_save_submenu_mnemonic_activates_the_item_immediately() {
use crate::i18n::Status;
let mut app = TuiApp::default(); press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Char('c')); assert!(
matches!(
&app.overlay,
Some(Overlay::FileSaveDest(FileKind::Collection, 0))
),
"picking a kind opens the save-destination step"
);
press(&mut app, KeyCode::Char('g')); assert!(app.overlay.is_none(), "no wizard for a non-git collection");
assert!(matches!(app.status, Some(Status::NoGitOrigin)));
}
#[test]
fn file_menu_left_and_right_arrows_enter_and_exit_submenus() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f')); press(&mut app, KeyCode::Right); assert!(matches!(app.overlay, Some(Overlay::FileLoadMenu(0))));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Right); assert!(matches!(
&app.overlay,
Some(Overlay::FileLoadSource(FileKind::Collection, 0))
));
press(&mut app, KeyCode::Left); assert!(matches!(app.overlay, Some(Overlay::FileLoadMenu(1))));
press(&mut app, KeyCode::Left); assert!(matches!(app.overlay, Some(Overlay::FileMenu(0))));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Right); assert!(matches!(app.overlay, Some(Overlay::FileSaveMenu(0))));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Right); assert!(matches!(
&app.overlay,
Some(Overlay::FileSaveDest(FileKind::Collection, 0))
));
press(&mut app, KeyCode::Left); assert!(matches!(app.overlay, Some(Overlay::FileSaveMenu(1))));
press(&mut app, KeyCode::Left); assert!(matches!(app.overlay, Some(Overlay::FileMenu(1))));
}
#[test]
fn file_submenu_esc_returns_to_the_parent_file_menu() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Esc);
assert!(
matches!(app.overlay, Some(Overlay::FileMenu(0))),
"Esc from Load returns to the top File menu"
);
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Esc);
assert!(
matches!(app.overlay, Some(Overlay::FileMenu(1))),
"Esc from Save returns to the top File menu"
);
}
#[test]
fn file_menu_mnemonics_are_unique_within_each_popup_and_avoid_nav_keys() {
use crate::i18n::Strings;
let langs = [Language::English, Language::French, Language::Danish];
for lang in langs {
let s = Strings::for_language(&lang);
for items in [
file_menu_items(&s).to_vec(),
file_load_items(&s).to_vec(),
file_save_items(&s).to_vec(),
file_load_source_items(&s).to_vec(),
file_save_dest_items(FileKind::Collection, &s),
file_save_dest_items(FileKind::Environment, &s),
file_save_dest_items(FileKind::Workspace, &s),
] {
let mnemonics: Vec<char> = items
.iter()
.map(|l| menu_mnemonic(l).expect("every item has a mnemonic"))
.collect();
let mut seen = std::collections::HashSet::new();
for m in &mnemonics {
assert!(
seen.insert(*m),
"mnemonic '{m}' is duplicated within a popup"
);
assert!(
!matches!(m, 'j' | 'k' | 'q'),
"mnemonic '{m}' collides with a menu nav key"
);
}
}
}
}
#[test]
fn file_menu_opens_the_remote_git_wizards() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert!(
matches!(&app.overlay, Some(Overlay::RemoteGit(w)) if w.kind == RemoteKind::Collection),
"opens the collection git wizard"
);
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert!(
matches!(&app.overlay, Some(Overlay::RemoteGit(w)) if w.kind == RemoteKind::Environment),
"opens the environment git wizard"
);
}
#[test]
fn connect_stage_toggles_fields_and_requires_a_url() {
let mut app = TuiApp::default();
app.open_remote_wizard(RemoteKind::Collection);
press(&mut app, KeyCode::Tab);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(matches!(w.stage, RemoteStage::Connect { field: 1, .. }));
}
_ => panic!("wizard closed"),
}
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => assert!(matches!(w.stage, RemoteStage::Error(_))),
_ => panic!("wizard closed"),
}
}
#[test]
fn esc_closes_the_wizard() {
let mut app = TuiApp::default();
app.open_remote_wizard(RemoteKind::Collection);
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none(), "Esc closes the wizard");
}
#[test]
fn ref_and_file_messages_advance_the_wizard() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Collection, Vec::new());
let refs = RemoteRefs {
branches: vec!["main".into()],
tags: vec!["v1".into()],
};
assert!(app.apply_git_msg(&mut w, GitMsg::Refs(Ok(refs))));
match &w.stage {
RemoteStage::PickRef { refs, .. } => {
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].gitref, "refs/heads/main");
assert_eq!(refs[1].gitref, "refs/tags/v1");
}
_ => panic!("expected PickRef"),
}
let files = vec!["a.hurl".to_string(), "sub/b.hurl".to_string()];
let repo = std::env::temp_dir().join(format!("crab_test_repo_{}", std::process::id()));
std::fs::create_dir_all(&repo).unwrap();
assert!(app.apply_git_msg(
&mut w,
GitMsg::Files(Ok((files, repo.clone(), "deadbeef".to_string())))
));
assert_eq!(w.repo.as_deref(), Some(repo.as_path()));
assert!(matches!(&w.stage, RemoteStage::PickFile { files, .. } if files.len() == 2));
std::fs::remove_dir_all(&repo).ok();
}
#[test]
fn fetched_collection_content_is_loaded_as_a_tab() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Collection, Vec::new());
w.selected_path = Some("api/health.hurl".to_string());
let before = app.collections.len();
let hurl = "# Health\nGET http://127.0.0.1:8080/health\nHTTP 200\n";
let keep_open = app.apply_git_msg(&mut w, GitMsg::Content(Ok(hurl.to_string())));
assert!(
!keep_open,
"a collection load closes the wizard once the tab is added"
);
assert_eq!(
app.collections.len(),
before + 1,
"a collection tab is added"
);
assert_eq!(app.collections.last().unwrap().name, "health");
assert!(
app.collections.last().unwrap().path.is_none(),
"remote source has no local path"
);
}
#[test]
fn fetched_collection_from_git_records_its_git_origin() {
use super::editor::Editor;
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Collection, Vec::new());
w.chosen_ref = Some(RefChoice {
label: "[Branches] main".into(),
gitref: "refs/heads/main".into(),
});
w.url = Editor::new("https://example.test/repo.git", false);
w.selected_path = Some("api/health.hurl".to_string());
let hurl = "GET http://127.0.0.1:8080/health\nHTTP 200\n";
let keep_open = app.apply_git_msg(&mut w, GitMsg::Content(Ok(hurl.to_string())));
assert!(!keep_open, "a collection load closes the wizard");
let ci = app.active_tab;
let col_origin = app.collections[ci]
.git_origin
.clone()
.expect("collection git_origin is set");
assert_eq!(col_origin.repo_url, "https://example.test/repo.git");
assert_eq!(col_origin.path, "api/health.hurl");
assert_eq!(col_origin.ref_kind, RefKind::Branch);
assert_eq!(col_origin.ref_name, "main");
assert!(
app.collections[ci].linked_env_id.is_none(),
"loading a collection no longer also loads/links an environment"
);
assert!(
app.global_envs.is_empty(),
"no environment is loaded alongside the collection"
);
}
#[test]
fn fetched_environment_content_is_loaded() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Environment, Vec::new());
w.selected_path = Some("envs/staging.vars".to_string());
let vars = "BASE_URL=http://127.0.0.1:8080\nTOKEN=abc\n";
let keep_open = app.apply_git_msg(&mut w, GitMsg::Content(Ok(vars.to_string())));
assert!(!keep_open, "loading closes the wizard");
assert_eq!(
app.global_envs.len(),
1,
"the environment is loaded globally"
);
}
#[test]
fn a_git_error_is_shown_in_the_wizard() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Collection, Vec::new());
assert!(app.apply_git_msg(&mut w, GitMsg::Refs(Err("boom".into()))));
assert!(matches!(&w.stage, RemoteStage::Error(e) if e == "boom"));
}
#[test]
fn workspace_git_filter_matches_extensions_case_insensitively() {
assert!(WorkspaceGitFilter::HurlAndJson.matches("a/b.hurl"));
assert!(WorkspaceGitFilter::HurlAndJson.matches("a/b.JSON"));
assert!(!WorkspaceGitFilter::HurlAndJson.matches("a/b.txt"));
assert!(WorkspaceGitFilter::HurlOnly.matches("a/b.Hurl"));
assert!(!WorkspaceGitFilter::HurlOnly.matches("a/b.json"));
assert!(WorkspaceGitFilter::JsonOnly.matches("a/b.json"));
assert!(!WorkspaceGitFilter::JsonOnly.matches("a/b.hurl"));
assert!(WorkspaceGitFilter::All.matches("a/b.bin"));
assert!(WorkspaceGitFilter::All.matches("a/b"));
}
#[test]
fn a_workspace_load_goes_to_the_file_filter_picker_instead_of_the_single_file_picker() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Workspace, Vec::new());
let files = vec![
"a.hurl".to_string(),
"b.json".to_string(),
"big/blob.bin".to_string(),
];
let repo = std::env::temp_dir().join(format!("crab_test_ws_repo_{}", std::process::id()));
std::fs::create_dir_all(&repo).unwrap();
assert!(app.apply_git_msg(
&mut w,
GitMsg::Files(Ok((files, repo.clone(), "deadbeef".to_string())))
));
assert!(matches!(
&w.stage,
RemoteStage::PickWorkspaceFilter { sel: 0 }
));
assert_eq!(
w.files.len(),
3,
"the full file listing is kept so the filter can be applied client-side"
);
std::fs::remove_dir_all(&repo).ok();
}
#[test]
fn picking_a_workspace_filter_with_no_matches_shows_an_error_instead_of_downloading_nothing() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Workspace, Vec::new());
w.files = vec!["big/blob.bin".to_string(), "readme.md".to_string()];
w.repo = Some(std::env::temp_dir().join("crab_test_ws_no_match"));
w.stage = RemoteStage::PickWorkspaceFilter { sel: 0 }; app.overlay = Some(Overlay::RemoteGit(Box::new(w)));
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(
matches!(&w.stage, RemoteStage::Error(_)),
"no matches shows an error instead of a silent no-op"
);
}
_ => panic!("expected the wizard to stay open showing the error"),
}
}
#[test]
fn a_successful_workspace_download_opens_a_new_workspace_tab_bound_to_the_repo_dir() {
use super::editor::Editor;
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Workspace, Vec::new());
w.url = Editor::new("https://example.test/my-repo.git", false);
let repo = std::env::temp_dir().join(format!("crab_test_ws_ready_{}", std::process::id()));
std::fs::create_dir_all(&repo).unwrap();
w.repo = Some(repo.clone());
let before = app.collections.len();
let keep_open = app.apply_git_msg(&mut w, GitMsg::Workspace(Ok(repo.clone())));
assert!(
!keep_open,
"the wizard itself closes; the new storage-choice popup takes over"
);
assert!(
w.repo.is_none(),
"repo ownership moves to the pending choice so poll_git_updates won't delete it"
);
assert!(
matches!(
&app.overlay,
Some(Overlay::WorkspaceStorageChoice { sel: 0, .. })
),
"the user is asked whether to keep the download temporary or save it permanently"
);
press(&mut app, KeyCode::Enter);
assert_eq!(
app.collections.len(),
before + 1,
"a new Workspace tab is created"
);
let ci = app.active_tab;
assert_eq!(
app.collections[ci].workspace_root.as_deref(),
Some(repo.as_path())
);
assert_eq!(
app.collections[ci].name, "my-repo",
"the tab is named from the repo URL"
);
assert!(
matches!(&app.overlay, Some(Overlay::WorkspacePicker(p)) if p.collection_idx == ci),
"the file picker opens immediately, scoped to the new tab, just like a local Workspace folder"
);
std::fs::remove_dir_all(&repo).ok();
}
#[test]
fn a_workspace_download_failure_is_shown_as_an_error() {
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Workspace, Vec::new());
assert!(app.apply_git_msg(&mut w, GitMsg::Workspace(Err("no space left".into()))));
assert!(matches!(&w.stage, RemoteStage::Error(e) if e == "no space left"));
}
#[test]
fn loading_a_workspace_offers_a_git_source_alongside_the_local_folder_picker() {
use crate::i18n::Strings;
let s = Strings::for_language(&Language::English);
let items = file_load_items(&s);
assert_eq!(items.len(), 4);
assert_eq!(file_load_kind_index(FileKind::Workspace), 3);
let mut app = TuiApp::default();
app.activate_file_load_item(3);
assert!(matches!(
&app.overlay,
Some(Overlay::FileLoadSource(FileKind::Workspace, 0))
));
app.activate_file_load_source(FileKind::Workspace, 1);
assert!(
matches!(&app.overlay, Some(Overlay::RemoteGit(w)) if w.kind == RemoteKind::Workspace),
"the git source opens the wizard in Workspace mode"
);
}
#[test]
fn the_load_source_step_esc_returns_to_the_kind_list_with_the_kind_relit() {
let mut app = TuiApp::default();
app.overlay = Some(Overlay::FileLoadSource(FileKind::Environment, 1));
press(&mut app, KeyCode::Esc);
assert!(
matches!(app.overlay, Some(Overlay::FileLoadMenu(2))),
"Esc steps back to the kind list with Environment (row 2) highlighted"
);
}
#[test]
fn the_save_destination_step_lists_the_right_choices_per_kind() {
use crate::i18n::Strings;
let s = Strings::for_language(&Language::English);
assert_eq!(file_save_dest_items(FileKind::Collection, &s).len(), 3);
assert_eq!(file_save_dest_items(FileKind::Environment, &s).len(), 2);
assert_eq!(file_save_dest_items(FileKind::Workspace, &s).len(), 2);
let mut app = TuiApp::default();
app.overlay = Some(Overlay::FileSaveDest(FileKind::Workspace, 0));
press(&mut app, KeyCode::Esc);
assert!(
matches!(app.overlay, Some(Overlay::FileSaveMenu(3))),
"Esc steps back to the Save kind list with Workspace (row 3) highlighted"
);
}
fn git_ws(args: &[&str], cwd: &std::path::Path) {
let out = std::process::Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git should run");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
fn seed_ws_bare_repo() -> (String, String, std::path::PathBuf) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"paperboy-ws-reload-test-{}-{nanos}",
std::process::id()
));
let bare = base.join("bare.git");
let work = base.join("work");
std::fs::create_dir_all(&bare).unwrap();
std::fs::create_dir_all(&work).unwrap();
git_ws(&["init", "--bare", "-q", "."], &bare);
git_ws(&["init", "-q"], &work);
git_ws(&["checkout", "-q", "-b", "main"], &work);
git_ws(&["config", "user.name", "Seed"], &work);
git_ws(&["config", "user.email", "seed@test"], &work);
std::fs::write(work.join("a.hurl"), "GET https://example.com/a\n").unwrap();
std::fs::write(work.join("b.json"), "{}").unwrap();
std::fs::write(work.join("big.bin"), "x".repeat(1024)).unwrap();
git_ws(&["add", "-A"], &work);
git_ws(&["commit", "-q", "-m", "seed"], &work);
git_ws(&["remote", "add", "origin", bare.to_str().unwrap()], &work);
git_ws(&["push", "-q", "origin", "main"], &work);
let sha = String::from_utf8(
std::process::Command::new("git")
.current_dir(&work)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
(bare.to_str().unwrap().to_string(), sha, base)
}
#[test]
fn spawn_workspace_redownload_fetches_the_exact_pinned_commit_and_applies_the_filter() {
let (repo_url, commit_sha, base) = seed_ws_bare_repo();
let origin = WorkspaceGitOrigin {
repo_url,
commit_sha,
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let rx = spawn_workspace_redownload(origin);
let result = rx
.recv()
.expect("the redownload thread should send a result");
let repo = result.expect("redownload of an existing commit should succeed");
assert!(repo.join("a.hurl").exists());
assert!(repo.join("b.json").exists());
assert!(
!repo.join("big.bin").exists(),
"the filter excludes non-hurl/json files, so it's never checked out"
);
crate::git_remote::cleanup(&repo);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn spawn_workspace_redownload_fails_clearly_when_the_pinned_commit_is_gone() {
let (repo_url, _sha, base) = seed_ws_bare_repo();
let origin = WorkspaceGitOrigin {
repo_url,
commit_sha: "0000000000000000000000000000000000dead".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::All,
};
let rx = spawn_workspace_redownload(origin);
let result = rx
.recv()
.expect("the redownload thread should send a result");
assert!(
result.is_err(),
"fetching a sha the remote no longer has should fail, not silently succeed"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_full_workspace_git_download_records_the_commit_sha_and_filter_as_the_origin() {
let (repo_url, commit_sha, base) = seed_ws_bare_repo();
let mut app = TuiApp::default();
let mut w = RemoteWizard::new(RemoteKind::Workspace, Vec::new());
w.url = crate::tui::editor::Editor::new(&repo_url, false);
w.chosen_ref = Some(RefChoice {
label: "[Branches] main".into(),
gitref: "refs/heads/main".into(),
});
let (files, repo_dir, listed_sha) =
crate::git_remote::list_files(&repo_url, None, "refs/heads/main").unwrap();
assert_eq!(listed_sha, commit_sha);
assert!(app.apply_git_msg(
&mut w,
GitMsg::Files(Ok((files, repo_dir.clone(), listed_sha.clone())))
));
assert_eq!(
w.chosen_sha.as_deref(),
Some(commit_sha.as_str()),
"the resolved commit sha is remembered"
);
assert!(matches!(
&w.stage,
RemoteStage::PickWorkspaceFilter { sel: 0 }
));
app.on_key_remote(
Box::new(w),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
let Some(Overlay::RemoteGit(mut w)) = app.overlay.take() else {
panic!("wizard should still be open")
};
assert_eq!(
w.chosen_workspace_filter,
Some(WorkspaceGitFilter::HurlAndJson)
);
assert!(matches!(w.stage, RemoteStage::Loading { .. }));
let rx =
w.rx.take()
.expect("the workspace download should have been spawned");
let msg = rx.recv().expect("the download thread should send a result");
let keep_open = app.apply_git_msg(&mut w, msg);
assert!(!keep_open);
assert!(matches!(
&app.overlay,
Some(Overlay::WorkspaceStorageChoice { sel: 0, .. })
));
press(&mut app, KeyCode::Enter);
let ci = app.active_tab;
let origin = app.collections[ci]
.workspace_git_origin
.clone()
.expect("a successful git Workspace download must record its origin");
assert_eq!(origin.repo_url, repo_url);
assert_eq!(origin.commit_sha, commit_sha);
assert_eq!(origin.ref_kind, RefKind::Branch);
assert_eq!(origin.ref_name, "main");
assert_eq!(origin.filter, WorkspaceGitFilter::HurlAndJson);
if let Some(root) = &app.collections[ci].workspace_root {
assert!(root.join("a.hurl").exists());
assert!(root.join("b.json").exists());
assert!(
!root.join("big.bin").exists(),
"the filter excludes non-hurl/json files"
);
crate::git_remote::cleanup(root);
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn into_collection_queues_a_pending_reload_only_when_the_vanished_root_came_from_git() {
let dir =
std::env::temp_dir().join(format!("paperboy_ws_pending_reload_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let origin = WorkspaceGitOrigin {
repo_url: "https://example.test/repo.git".into(),
commit_sha: "abc123".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let mut col = Collection::new("ghost-repo".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
col.workspace_git_origin = Some(origin.clone());
col.path = Some(dir.join("sub/a.hurl"));
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
let (restored, pending) = persisted.into_collection(None);
assert_eq!(restored.workspace_root, None);
let pending = pending
.expect("a git-originated vanished root should queue a reload instead of just resetting");
assert_eq!(pending.tab_name, "ghost-repo");
assert_eq!(pending.origin, origin);
assert_eq!(
pending.relative_selected_path.as_deref(),
Some("sub/a.hurl"),
"the previously-selected file's path relative to the dead root is captured for later re-selection"
);
}
#[test]
fn apply_persisted_queues_pending_reloads_and_opens_the_first_confirm_popup() {
let dir =
std::env::temp_dir().join(format!("paperboy_ws_apply_pending_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let origin = WorkspaceGitOrigin {
repo_url: "https://example.test/repo.git".into(),
commit_sha: "abc123".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let mut col = Collection::new("ghost-repo".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
col.workspace_git_origin = Some(origin);
let tab = crate::persistence::PersistedTab::from_collection(&col, None);
let state = crate::persistence::PersistedState {
tabs: vec![tab],
..Default::default()
};
let mut app = TuiApp::default();
app.apply_persisted(state);
assert_eq!(app.collections[0].workspace_root, None);
assert!(
app.status.is_none(),
"no plain 'missing' status when a redownload will be offered instead"
);
assert!(
matches!(&app.overlay, Some(Overlay::WorkspaceReloadConfirm { idx, .. }) if *idx == 0),
"the confirm popup opens automatically for the (only) pending reload"
);
}
#[test]
fn declining_a_workspace_reload_shows_the_folder_missing_status_and_advances_the_queue() {
let origin = WorkspaceGitOrigin {
repo_url: "https://example.test/repo.git".into(),
commit_sha: "abc123".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let reload = crate::persistence::PendingWorkspaceReload {
tab_name: "ghost-repo".into(),
origin,
relative_selected_path: None,
};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("ghost-repo".into(), Vec::new()));
app.overlay = Some(Overlay::WorkspaceReloadConfirm {
idx: 0,
reload: Box::new(reload),
sel: 0,
});
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none());
match &app.status {
Some(crate::i18n::Status::WorkspaceFolderMissing(name)) => assert_eq!(name, "ghost-repo"),
other => panic!("expected WorkspaceFolderMissing, got {other:?}"),
}
}
#[test]
fn accepting_a_workspace_reload_redownloads_and_restores_the_previously_selected_file() {
let (repo_url, commit_sha, base) = seed_ws_bare_repo();
let origin = WorkspaceGitOrigin {
repo_url,
commit_sha,
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let reload = crate::persistence::PendingWorkspaceReload {
tab_name: "ghost-repo".into(),
origin,
relative_selected_path: Some("a.hurl".to_string()),
};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("ghost-repo".into(), Vec::new()));
app.overlay = Some(Overlay::WorkspaceReloadConfirm {
idx: 0,
reload: Box::new(reload),
sel: 0,
});
press(&mut app, KeyCode::Enter);
assert!(
matches!(&app.overlay, Some(Overlay::WorkspaceReloadLoading { idx }) if *idx == 0),
"accepting spawns the background redownload and shows a loading popup for the right tab"
);
for _ in 0..200 {
app.poll_workspace_redownload_updates();
if app.overlay.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(
app.overlay.is_none(),
"the loading popup closes once the redownload settles"
);
assert!(matches!(
app.status,
Some(crate::i18n::Status::WorkspaceReloaded)
));
let col = &app.collections[0];
assert!(
col.workspace_root.is_some(),
"the tab is rebound to the freshly downloaded folder"
);
assert!(col.workspace_downloaded_from_git);
assert_eq!(
col.path
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str()),
Some("a.hurl"),
"the previously-selected file is re-selected in the new checkout"
);
assert!(
!col.entries.is_empty(),
"the re-selected file's requests are parsed"
);
if let Some(root) = &app.collections[0].workspace_root {
crate::git_remote::cleanup(root);
}
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_failed_workspace_reload_reports_the_error_and_hints_at_saving_locally() {
let origin = WorkspaceGitOrigin {
repo_url: std::env::temp_dir()
.join(format!(
"paperboy-ws-reload-nonexistent-{}",
std::process::id()
))
.to_string_lossy()
.into_owned(),
commit_sha: "deadbeef".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::All,
};
let reload = crate::persistence::PendingWorkspaceReload {
tab_name: "ghost-repo".into(),
origin,
relative_selected_path: None,
};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("ghost-repo".into(), Vec::new()));
app.overlay = Some(Overlay::WorkspaceReloadConfirm {
idx: 0,
reload: Box::new(reload),
sel: 0,
});
press(&mut app, KeyCode::Enter);
for _ in 0..200 {
app.poll_workspace_redownload_updates();
if app.overlay.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
match &app.status {
Some(crate::i18n::Status::WorkspaceReloadFailed(_)) => {}
other => panic!("expected WorkspaceReloadFailed, got {other:?}"),
}
let s = crate::i18n::Strings::for_language(&Language::English);
let text = app.status.as_ref().unwrap().text(&s);
assert!(
text.contains(s.workspace_reload_save_hint),
"the failure message hints at saving the Workspace locally: {text}"
);
}
#[test]
fn multiple_pending_workspace_reloads_are_offered_one_at_a_time() {
let origin = WorkspaceGitOrigin {
repo_url: "https://example.test/repo.git".into(),
commit_sha: "abc123".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let dir_a = std::env::temp_dir().join(format!("paperboy_ws_multi_a_{}", std::process::id()));
let dir_b = std::env::temp_dir().join(format!("paperboy_ws_multi_b_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir_a);
let _ = std::fs::remove_dir_all(&dir_b);
let mut col_a = Collection::new("repo-a".to_string(), Vec::new());
col_a.workspace_root = Some(dir_a);
col_a.workspace_downloaded_from_git = true;
col_a.workspace_git_origin = Some(origin.clone());
let mut col_b = Collection::new("repo-b".to_string(), Vec::new());
col_b.workspace_root = Some(dir_b);
col_b.workspace_downloaded_from_git = true;
col_b.workspace_git_origin = Some(origin);
let tab_a = crate::persistence::PersistedTab::from_collection(&col_a, None);
let tab_b = crate::persistence::PersistedTab::from_collection(&col_b, None);
let state = crate::persistence::PersistedState {
tabs: vec![tab_a, tab_b],
..Default::default()
};
let mut app = TuiApp::default();
app.apply_persisted(state);
assert!(
matches!(&app.overlay, Some(Overlay::WorkspaceReloadConfirm { idx, .. }) if *idx == 0),
"only the first pending reload is shown initially"
);
press(&mut app, KeyCode::Esc); assert!(
matches!(&app.overlay, Some(Overlay::WorkspaceReloadConfirm { idx, .. }) if *idx == 1),
"declining the first immediately offers the second"
);
press(&mut app, KeyCode::Esc); assert!(
app.overlay.is_none(),
"no more pending reloads left to show"
);
}
#[test]
fn filter_indices_is_case_insensitive_and_substring() {
let items = ["main", "develop", "release/1.0"];
assert_eq!(filter_indices(items.iter().copied(), ""), vec![0, 1, 2]);
assert_eq!(filter_indices(items.iter().copied(), "RE"), vec![2]);
assert_eq!(filter_indices(items.iter().copied(), "e"), vec![1, 2]);
assert_eq!(filter_indices(items.iter().copied(), "1.0"), vec![2]);
}
fn app_with_resolved_secret(secret: &str) -> TuiApp {
let mut app = TuiApp::default();
let (mut env, pending) =
crate::environment::parse_vars_pending("e".into(), "TOKEN={{ op://V/i/f }}");
env.apply_update(&crate::environment::EnvUpdate {
env_id: env.id,
index: pending[0].index,
value: Some(secret.to_string()),
});
let env_id = add_global_env(&mut app, env);
app.collections[0].linked_env_id = Some(env_id);
app.focus = Pane::GlobalEnv;
app.global_env_idx = 0;
app
}
#[test]
fn editing_a_secret_opens_a_masked_prompt_with_reset() {
let mut app = app_with_resolved_secret("s3cr3t");
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::Prompt {
mask,
reset_to,
editor,
kind,
..
}) => {
assert!(*mask, "a secret is masked while editing");
assert_eq!(
reset_to.as_deref(),
Some("s3cr3t"),
"reset target is the loaded value"
);
assert_eq!(
editor.text(),
"s3cr3t",
"prefilled with the real value (shown masked)"
);
assert!(
matches!(kind, PromptKind::EnvValue(env_id, 0) if *env_id == only_env_id(&app))
);
}
_ => panic!("expected the env-value prompt to open"),
}
}
#[test]
fn committing_a_new_secret_value_marks_it_modified() {
let mut app = app_with_resolved_secret("s3cr3t");
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(only_env_id(&app), 0),
"my-own-token".into(),
true,
);
let v = &only_env(&app).vars[0];
assert!(v.modified, "a changed value is flagged modified");
assert_eq!(v.value, "my-own-token");
assert_eq!(
v.original_value, "s3cr3t",
"the original loaded value is kept for reset"
);
assert!(v.resolved);
}
#[test]
fn committing_the_same_value_is_not_modified() {
let mut app = app_with_resolved_secret("s3cr3t");
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(only_env_id(&app), 0),
"s3cr3t".into(),
true,
);
assert!(!only_env(&app).vars[0].modified);
}
#[test]
fn ctrl_r_resets_the_edit_to_the_original_value() {
let mut app = app_with_resolved_secret("s3cr3t");
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Char('X')); match &app.overlay {
Some(Overlay::Prompt {
editor,
secret_intact,
..
}) => {
assert_eq!(
editor.text(),
"X",
"typing replaces the whole secret, not appends"
);
assert!(
!secret_intact,
"the secret is no longer intact after typing"
);
}
_ => panic!("prompt not open"),
}
app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); match &app.overlay {
Some(Overlay::Prompt {
editor,
secret_intact,
..
}) => {
assert_eq!(
editor.text(),
"s3cr3t",
"Ctrl+R restores the original value"
);
assert!(secret_intact, "reset returns to the intact (masked) state");
}
_ => panic!("prompt not open"),
}
}
#[test]
fn backspace_on_an_intact_secret_clears_it_entirely() {
let mut app = app_with_resolved_secret("longsecretvalue");
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Backspace); match &app.overlay {
Some(Overlay::Prompt {
editor,
secret_intact,
..
}) => {
assert_eq!(editor.text(), "", "one backspace clears the entire secret");
assert!(!secret_intact);
}
_ => panic!("prompt not open"),
}
}
#[test]
fn intact_secret_renders_fixed_eight_dots_regardless_of_length() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let render_secret_dots = |secret: &str| -> usize {
let mut app = app_with_resolved_secret(secret);
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter);
let mut term = Terminal::new(TestBackend::new(72, 6)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
buffer_text(term.backend().buffer())
.matches('\u{2022}')
.count()
};
let short = render_secret_dots("ab");
let long = render_secret_dots("this-is-a-very-long-secret-value");
assert_eq!(short, long, "intact secret shows a fixed number of dots");
assert_eq!(
short, 8,
"exactly eight dots are shown (matching SECRET_MASK)"
);
}
#[test]
fn git_icon_shown_on_tab_and_requests_list_title_only_for_git_origin_collections() {
use crate::git_remote::{GitOrigin, RefKind};
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
app.collections
.push(Collection::new("from-git".into(), Vec::new()));
app.collections.last_mut().unwrap().git_origin = Some(GitOrigin {
repo_url: "https://example.test/repo.git".into(),
path: "api/health.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
});
app.collections
.push(Collection::new("local-only".into(), Vec::new()));
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| {
let area = f.area();
super::draw::draw_tabs(
f,
ratatui::layout::Rect { height: 3, ..area },
&app,
&s,
&th,
);
})
.unwrap();
let tabs_text = buffer_text(term.backend().buffer());
assert!(
tabs_text.contains(&format!("{} from-git", super::draw::GIT_ICON)),
"git tab gets the icon"
);
assert!(
!tabs_text.contains(&format!("{} local-only", super::draw::GIT_ICON)),
"local tab has no icon"
);
let git_ci = app
.collections
.iter()
.position(|c| c.name == "from-git")
.unwrap();
let mut term2 = Terminal::new(TestBackend::new(80, 24)).unwrap();
term2
.draw(|f| {
let area = f.area();
super::draw::draw_collection_left(f, area, &app, git_ci, &s, &th);
})
.unwrap();
let list_text = buffer_text(term2.backend().buffer());
assert!(
list_text.contains(&format!("{} from-git", super::draw::GIT_ICON)),
"requests list title gets the icon too"
);
}
#[test]
fn word_wrap_keeps_words_whole_and_only_hard_breaks_an_overlong_word() {
let wrapped = super::draw::word_wrap("in wizard tables: toggle row enabled, delete row", 20);
assert!(
wrapped.iter().all(|l| l.chars().count() <= 20),
"no line exceeds the requested width: {wrapped:?}"
);
let rejoined: Vec<&str> = wrapped.iter().flat_map(|l| l.split_whitespace()).collect();
let original: Vec<&str> = "in wizard tables: toggle row enabled, delete row"
.split_whitespace()
.collect();
assert_eq!(
rejoined, original,
"no words are dropped or reordered by wrapping"
);
let broken = super::draw::word_wrap("supercalifragilisticexpialidocious", 10);
assert!(
broken.len() > 1,
"an overlong single word is split across multiple lines"
);
assert!(broken.iter().all(|l| l.chars().count() <= 10));
assert_eq!(
broken.concat(),
"supercalifragilisticexpialidocious",
"no characters lost when hard-breaking"
);
}
#[test]
fn help_entry_wraps_continuation_lines_with_a_hanging_indent_under_the_description_column() {
let lines =
super::draw::help_entry_lines("", "in wizard tables: toggle row enabled, delete row", 40);
assert!(
lines.len() > 1,
"the description is long enough to wrap at this width"
);
let render = |l: &ratatui::text::Line| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
};
let first = render(&lines[0]);
let second = render(&lines[1]);
assert_eq!(
&first[..18],
" ",
"first line reserves the 17-col key column + 1 space"
);
assert_eq!(
&second[..18],
" ",
"continuation line is indented to the same column"
);
assert_ne!(
second.trim_start(),
"",
"continuation line still carries text"
);
let long_shortcut = "[ / ], PgUp/PgDn, ^\u{2190}/\u{2192}";
let lines2 =
super::draw::help_entry_lines(long_shortcut, "previous or next tab in the list", 30);
if lines2.len() > 1 {
let expected_indent = long_shortcut.chars().count() + 1;
let cont = render(&lines2[1]);
assert!(
cont.chars().take(expected_indent).all(|c| c == ' '),
"indent matches the long shortcut's own width"
);
}
}
#[test]
fn help_popup_widens_on_a_spacious_terminal_but_stays_within_a_narrow_one() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let box_right_edge = |term_w: u16, term_h: u16| -> u16 {
let mut app = TuiApp {
overlay: Some(Overlay::Help(0)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(term_w, term_h)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
let top_row = text
.lines()
.find(|l| l.contains('┌'))
.expect("help box renders a top border");
let left = top_row.chars().position(|c| c == '┌').unwrap();
let right = top_row.chars().position(|c| c == '┐').unwrap();
(right - left) as u16
};
let narrow = box_right_edge(70, 40);
let spacious = box_right_edge(160, 40);
assert!(
spacious > narrow,
"the help box is wider on a spacious terminal ({spacious} vs {narrow})"
);
assert!(
spacious <= 100,
"the help box width is capped so it doesn't become absurd on huge terminals"
);
assert!(
narrow <= 70,
"the help box never exceeds the terminal's own width"
);
}
#[test]
fn help_popup_opens_on_the_shortcuts_tab_and_tab_key_switches_to_glossary() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('?'));
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"? opens Help on the Shortcuts tab"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.overlay, Some(Overlay::Help(1))),
"Tab switches to the Glossary tab"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"Tab from Glossary wraps back to Shortcuts"
);
press(&mut app, KeyCode::Right);
assert!(
matches!(app.overlay, Some(Overlay::Help(1))),
"Right arrow also switches tabs"
);
press(&mut app, KeyCode::Left);
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"Left arrow switches back"
);
}
#[test]
fn help_popup_any_other_key_closes_it_from_either_tab() {
let mut app = TuiApp {
overlay: Some(Overlay::Help(0)),
..Default::default()
};
press(&mut app, KeyCode::Esc);
assert!(
app.overlay.is_none(),
"Esc closes Help from the Shortcuts tab"
);
let mut app = TuiApp {
overlay: Some(Overlay::Help(1)),
..Default::default()
};
press(&mut app, KeyCode::Char('q'));
assert!(app.overlay.is_none(), "q closes Help from the Glossary tab");
}
#[test]
fn help_glossary_tab_renders_every_substitution_colour_and_the_shadow_icon() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::Help(1)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(text.contains(s.glossary_label_literal));
assert!(text.contains(s.glossary_label_loaded));
assert!(text.contains(s.glossary_label_pending));
assert!(text.contains(s.glossary_label_failed));
assert!(text.contains(s.glossary_label_shadowed));
assert!(
text.contains(super::draw::SHADOW_ICON),
"the shadow icon itself is shown, matching the inline marker"
);
assert!(
text.contains(s.help_tab_glossary),
"the popup title reflects the active Glossary tab"
);
}
#[test]
fn help_glossary_tab_also_renders_every_other_app_icon() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::Help(1)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 50)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(text.contains(s.glossary_heading_icons));
assert!(text.contains(s.glossary_label_modified));
assert!(text.contains(s.glossary_label_added));
assert!(text.contains(s.glossary_label_passed));
assert!(text.contains(s.glossary_label_run_failed));
assert!(text.contains(s.glossary_label_running));
assert!(text.contains(s.glossary_label_git));
assert!(text.contains(s.glossary_label_linked));
assert!(text.contains(s.glossary_label_folder));
assert!(text.contains(s.glossary_label_scroll_hint));
assert!(
text.contains(super::draw::GIT_ICON),
"the git icon glyph itself is shown"
);
assert!(
text.contains(super::draw::LINK_ICON),
"the link icon glyph itself is shown"
);
assert!(
text.contains(super::draw::FOLDER_ICON),
"the folder icon glyph itself is shown"
);
}
#[test]
fn glossary_entries_with_a_double_width_emoji_icon_align_their_wrapped_description_like_any_other_row()
{
use crate::i18n::{Language, Strings};
let s = Strings::for_language(&Language::English);
let lines = super::draw::glossary_entry_lines(
super::draw::FOLDER_ICON,
ratatui::style::Color::White,
s.glossary_label_folder,
s.glossary_desc_folder,
70,
);
assert!(
lines.len() >= 2,
"the folder description wraps to at least two lines at this width"
);
let first_spans = &lines[0].spans;
assert_eq!(
first_spans.len(),
3,
"expected header + padding + description spans"
);
let first_desc_col = first_spans[0].width() + first_spans[1].width();
let second_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
let second_indent = second_text.chars().take_while(|c| *c == ' ').count();
assert_eq!(
first_desc_col, second_indent,
"the first line's description must start at the same column as wrapped continuation lines"
);
}
#[test]
fn help_popup_shows_both_tab_names_regardless_of_which_is_active() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
for tab in [0, 1] {
let mut app = TuiApp {
overlay: Some(Overlay::Help(tab)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(s.help_tab_shortcuts),
"Shortcuts tab label visible while on tab {tab}"
);
assert!(
text.contains(s.help_tab_glossary),
"Glossary tab label visible while on tab {tab}"
);
}
}
#[test]
fn help_popup_stays_the_same_height_on_both_tabs() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let box_height = |tab: usize| -> usize {
let mut app = TuiApp {
overlay: Some(Overlay::Help(tab)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 60)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
let top = text
.lines()
.position(|l| l.contains('┌'))
.expect("top border");
let bottom = text
.lines()
.position(|l| l.contains('└'))
.expect("bottom border");
bottom - top
};
assert_eq!(
box_height(0),
box_height(1),
"the popup keeps one fixed height across both tabs"
);
}
#[test]
fn help_shortcuts_tab_groups_entries_into_titled_sections() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::Help(0)),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 60)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
for heading in [
s.help_group_navigation,
s.help_group_tabs,
s.help_group_requests,
s.help_group_menus,
s.help_group_environments,
s.help_group_editing,
s.help_group_panels,
] {
assert!(
text.contains(heading),
"section heading {heading:?} is shown"
);
}
for desc in [
s.help_focus,
s.help_move,
s.help_page_response,
s.help_switch_tabs,
s.help_select,
s.help_run,
s.help_run_all,
s.help_new,
s.help_raw_mode,
s.help_raw_json,
s.help_base_url,
s.help_menus,
s.help_workspace_browse,
s.help_prev_next_tab,
s.help_rename_close,
s.help_reload_var,
s.help_env_rename,
s.help_env_activate,
s.help_env_delete,
s.help_env_link,
s.help_env_view_linked,
s.help_tab_manage,
s.help_restore_request,
s.help_tab_reorder,
s.help_multi_select,
s.help_resize,
s.help_resize_width,
s.help_save_editor,
s.help_cancel,
s.help_quit,
] {
assert!(
text.contains(desc),
"shortcut description {desc:?} still appears after grouping"
);
}
assert!(
text.contains("copy the selection"),
"the copy-selection shortcut description still appears (possibly wrapped)"
);
}
#[test]
fn help_section_headings_render_as_a_dashed_divider_with_no_surrounding_blank_lines() {
use crate::i18n::{Language, Strings};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let line = super::draw::help_section_divider(s.help_group_navigation, 50, &th);
let rendered: String = line.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
rendered.starts_with("──"),
"the divider opens with a short dash rule: {rendered:?}"
);
assert!(
rendered.contains(s.help_group_navigation),
"the title text itself is still shown"
);
assert!(
rendered.trim_end().ends_with('─'),
"the divider closes with a dash rule filling the rest of the width: {rendered:?}"
);
assert_eq!(
rendered.chars().count(),
50,
"the divider fills the full requested width"
);
}
#[test]
fn help_popup_up_down_scroll_the_body_instead_of_closing_it() {
let mut app = TuiApp {
overlay: Some(Overlay::Help(0)),
..Default::default()
};
press(&mut app, KeyCode::Down);
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"Down scrolls instead of closing Help"
);
assert_eq!(app.help_scroll, 1);
press(&mut app, KeyCode::Down);
assert_eq!(app.help_scroll, 2);
press(&mut app, KeyCode::Up);
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"Up scrolls instead of closing Help"
);
assert_eq!(app.help_scroll, 1);
press(&mut app, KeyCode::PageDown);
assert_eq!(app.help_scroll, 11);
press(&mut app, KeyCode::Home);
assert_eq!(app.help_scroll, 0);
}
#[test]
fn help_popup_shows_a_scrollbar_and_clamps_scroll_when_the_body_is_taller_than_the_terminal() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::Help(0)),
help_scroll: u16::MAX,
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 12)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains('█'),
"a scrollbar thumb is drawn when the body overflows the popup: {text}"
);
assert!(
app.help_scroll < 200,
"help_scroll is clamped to the body's actual max scroll, not left at u16::MAX"
);
let mut app2 = TuiApp {
overlay: Some(Overlay::Help(0)),
..Default::default()
};
let mut term2 = Terminal::new(TestBackend::new(120, 80)).unwrap();
term2
.draw(|f| super::draw::draw_overlay(f, &mut app2, &s, &th))
.unwrap();
let text2 = buffer_text(term2.backend().buffer());
assert!(
!text2.contains('█'),
"no scrollbar thumb when the whole body already fits: {text2}"
);
}
#[test]
fn menu_bar_shows_the_mnemonic_baked_into_file_and_settings_labels() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let app = TuiApp::default();
let mut term = Terminal::new(TestBackend::new(60, 1)).unwrap();
term.draw(|f| super::draw::draw_menu(f, f.area(), &app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(text.contains("File"), "the File label is shown");
assert!(text.contains("Settings"), "the Settings label is shown");
assert!(
!text.contains("(F)ile"),
"brackets are replaced by underline styling"
);
assert!(
!text.contains("(S)ettings"),
"brackets are replaced by underline styling"
);
let row = text.lines().next().unwrap();
let f_col = row.find("File").expect("File present") as u16;
let s_col = row.find("Settings").expect("Settings present") as u16;
let buf = term.backend().buffer();
assert!(
buf[(f_col, 0)]
.modifier
.contains(ratatui::style::Modifier::UNDERLINED),
"the 'F' mnemonic letter itself is underlined"
);
assert!(
buf[(s_col, 0)]
.modifier
.contains(ratatui::style::Modifier::UNDERLINED),
"the 'S' mnemonic letter itself is underlined"
);
}
#[test]
fn git_icon_shown_on_env_heading_independently_of_the_collection_origin() {
use crate::environment::Environment;
use crate::git_remote::{GitOrigin, RefKind};
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
app.collections
.push(Collection::new("local-collection".into(), Vec::new()));
let ci = app.collections.len() - 1;
let env = Environment {
id: 0,
name: "staging".into(),
vars: Vec::new(),
path: None,
git_origin: Some(GitOrigin {
repo_url: "https://example.test/repo.git".into(),
path: "envs/staging.vars".into(),
ref_kind: RefKind::Tag,
ref_name: "v1.0".into(),
}),
};
let env_id = add_global_env(&mut app, env);
app.collections[ci].linked_env_id = Some(env_id);
assert!(
app.collections[ci].git_origin.is_none(),
"collection itself was not loaded from git"
);
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
let popup = EnvPopupState::new(env_id);
term.draw(|f| super::draw::draw_env_popup(f, &app, &popup, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(&format!("{} staging", super::draw::GIT_ICON)),
"env heading gets the icon from env git_origin"
);
}
#[test]
fn save_to_git_menu_item_is_refused_without_a_remembered_git_origin() {
use crate::i18n::Status;
let mut app = TuiApp::default(); press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); for _ in 0..2 {
press(&mut app, KeyCode::Down); }
press(&mut app, KeyCode::Enter);
assert!(
app.overlay.is_none(),
"the menu just closes — no wizard for a non-git collection"
);
assert!(matches!(app.status, Some(Status::NoGitOrigin)));
}
#[test]
fn save_to_git_wizard_is_prefilled_from_the_collections_remembered_origin() {
use crate::git_remote::{GitOrigin, RefKind};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("api".into(), Vec::new()));
let ci = app.collections.len() - 1;
app.collections[ci].git_origin = Some(GitOrigin {
repo_url: "https://example.test/repo.git".into(),
path: "api/health.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
});
app.active_tab = ci;
app.open_git_save_wizard();
match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(matches!(w.stage, GitSaveStage::Connect { field: 0 }));
assert_eq!(
w.url.text(),
"https://example.test/repo.git",
"the URL autocompletes from the remembered origin"
);
assert_eq!(w.collection_path.text(), "api/health.hurl");
assert_eq!(
w.target_name.text(),
"main",
"defaults to appending to the originally-loaded branch"
);
assert!(w.target_kind == GitSaveTarget::Branch);
}
_ => panic!("expected the git-save wizard to open"),
}
}
#[test]
fn choose_paths_checkbox_toggles_and_tab_skips_the_hidden_env_path_field() {
use crate::git_remote::{GitOrigin, RefKind};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("api".into(), Vec::new()));
let ci = app.collections.len() - 1;
let env_id = add_empty_global_env(&mut app, "e");
app.collections[ci].linked_env_id = Some(env_id);
app.collections[ci].git_origin = Some(GitOrigin {
repo_url: "https://example.test/repo.git".into(),
path: "api/health.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
});
app.active_tab = ci;
app.open_git_save_wizard();
{
let Some(Overlay::GitSave(w)) = &mut app.overlay else {
panic!()
};
w.stage = GitSaveStage::ChoosePaths { field: 0 };
assert!(
w.include_env,
"an attached environment defaults to being included"
);
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Char(' '));
match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(!w.include_env, "space toggles the checkbox");
assert!(matches!(w.stage, GitSaveStage::ChoosePaths { field: 1 }));
}
_ => panic!(),
}
press(&mut app, KeyCode::Tab); match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(matches!(w.stage, GitSaveStage::ChoosePaths { field: 0 }))
}
_ => panic!("unchecking the env removed field 2 from the tab cycle"),
}
}
#[test]
fn choose_target_picking_an_existing_branch_from_the_dropdown_marks_it_as_existing() {
let mut app = TuiApp::default();
let mut w = Box::new(app_git_save_wizard(&mut app, "main"));
w.stage = GitSaveStage::ChooseTarget {
sel: None,
refs: Some(RemoteRefs {
branches: vec!["main".into(), "develop".into()],
tags: vec!["v1".into()],
}),
};
app.overlay = Some(Overlay::GitSave(w));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter); match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert_eq!(w.target_name.text(), "develop");
assert!(matches!(
w.stage,
GitSaveStage::ChooseTarget { sel: None, .. }
));
}
_ => panic!(),
}
press(&mut app, KeyCode::Enter); match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(matches!(w.stage, GitSaveStage::CommitMessage));
assert!(w.target_intent == TargetIntent::ExistingBranch);
}
_ => panic!("expected to move to the commit-message step"),
}
}
#[test]
fn choose_target_typing_a_brand_new_branch_name_marks_it_as_new() {
let mut app = TuiApp::default();
let mut w = Box::new(app_git_save_wizard(&mut app, "main"));
w.target_name = super::editor::Editor::blank();
w.stage = GitSaveStage::ChooseTarget {
sel: None,
refs: Some(RemoteRefs {
branches: vec!["main".into()],
tags: vec![],
}),
};
app.overlay = Some(Overlay::GitSave(w));
for ch in "feature-x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(matches!(w.stage, GitSaveStage::CommitMessage));
assert_eq!(w.target_name.text(), "feature-x");
assert!(
w.target_intent == TargetIntent::NewRef,
"a name not on the remote is a brand-new ref"
);
}
_ => panic!(),
}
}
#[test]
fn choose_target_tab_toggles_between_branch_and_tag() {
let mut app = TuiApp::default();
let w = Box::new(app_git_save_wizard(&mut app, "main"));
app.overlay = Some(Overlay::GitSave(w));
match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(w.target_kind == GitSaveTarget::Branch, "starts on Branch")
}
_ => panic!(),
}
press(&mut app, KeyCode::Tab);
match &app.overlay {
Some(Overlay::GitSave(w)) => {
assert!(w.target_kind == GitSaveTarget::Tag, "Tab toggles to Tag")
}
_ => panic!(),
}
}
fn app_git_save_wizard(app: &mut TuiApp, branch: &str) -> GitSaveWizard {
use crate::git_remote::{GitOrigin, RefKind};
app.collections
.push(Collection::new("api".into(), Vec::new()));
let ci = app.collections.len() - 1;
app.collections[ci].git_origin = Some(GitOrigin {
repo_url: "https://example.test/repo.git".into(),
path: "api/health.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: branch.to_string(),
});
app.active_tab = ci;
let mut w = GitSaveWizard::new(ci, &app.collections[ci], app.effective_env(ci));
w.stage = GitSaveStage::ChooseTarget {
sel: None,
refs: None,
};
w
}
#[test]
fn saving_to_git_appends_a_commit_and_updates_the_remembered_origin_and_markers() {
use crate::git_remote::{GitOrigin, RefKind};
use crate::i18n::Status;
use std::process::Command;
fn git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git should run");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"paperboy-git-save-tui-test-{}-{nanos}",
std::process::id()
));
let bare = base.join("bare.git");
let work = base.join("work");
std::fs::create_dir_all(&bare).unwrap();
std::fs::create_dir_all(&work).unwrap();
git(&["init", "--bare", "-q", "."], &bare);
git(&["init", "-q"], &work);
git(&["checkout", "-q", "-b", "main"], &work);
git(&["config", "user.name", "Seed"], &work);
git(&["config", "user.email", "seed@test"], &work);
std::fs::write(work.join("api.hurl"), "GET https://example.com\n").unwrap();
git(&["add", "-A"], &work);
git(&["commit", "-q", "-m", "seed"], &work);
git(&["remote", "add", "origin", bare.to_str().unwrap()], &work);
git(&["push", "-q", "origin", "main"], &work);
let bare_url = bare.to_str().unwrap().to_string();
let mut app = TuiApp::default();
let mut entry = HurlEntry {
method: "GET".into(),
url: "https://example.com".into(),
..Default::default()
};
entry.modified = true;
let mut col = Collection::new("api".into(), vec![entry]);
col.git_origin = Some(GitOrigin {
repo_url: bare_url.clone(),
path: "api.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
});
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.open_git_save_wizard();
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("wizard should be open")
};
w.include_env = false;
w.stage = GitSaveStage::CommitMessage;
app.overlay = Some(Overlay::GitSave(w));
press(&mut app, KeyCode::Enter);
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("wizard should still be open (Pushing)")
};
assert!(matches!(w.stage, GitSaveStage::Pushing));
let rx = w.rx.take().expect("the push should have been spawned");
let msg = rx.recv().expect("the push thread should send a result");
let keep_open = app.apply_git_save_msg(&mut w, msg);
assert!(keep_open, "the Done stage stays open until dismissed");
assert!(
matches!(w.stage, GitSaveStage::Done),
"a successful push moves to Done"
);
assert!(matches!(app.status, Some(Status::GitSaved)));
assert!(
!app.collections[ci].entries[0].modified,
"the modified marker is cleared after a successful save"
);
let origin = app.collections[ci].git_origin.as_ref().unwrap();
assert_eq!(
origin.ref_name, "main",
"a branch-target save keeps remembering the same branch"
);
assert_eq!(origin.repo_url, bare_url);
git(&["fetch", "-q", "origin", "main"], &work);
let log = Command::new("git")
.current_dir(&work)
.args(["log", "-1", "--format=%s", "origin/main"])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&log.stdout).trim(),
"Update api via PaperBoy"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn saving_a_workspace_to_git_commits_the_whole_tree_and_repins_the_origin_sha() {
use crate::i18n::Status;
use std::process::Command;
fn git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git should run");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"paperboy-ws-git-save-test-{}-{nanos}",
std::process::id()
));
let bare = base.join("bare.git");
let seed = base.join("seed");
let ws = base.join("ws"); std::fs::create_dir_all(&bare).unwrap();
std::fs::create_dir_all(&seed).unwrap();
std::fs::create_dir_all(ws.join("api")).unwrap();
git(&["init", "--bare", "-q", "."], &bare);
git(&["init", "-q"], &seed);
git(&["checkout", "-q", "-b", "main"], &seed);
git(&["config", "user.name", "Seed"], &seed);
git(&["config", "user.email", "seed@test"], &seed);
std::fs::create_dir_all(seed.join("api")).unwrap();
std::fs::write(seed.join("api/health.hurl"), "GET https://old\n").unwrap();
git(&["add", "-A"], &seed);
git(&["commit", "-q", "-m", "seed"], &seed);
git(&["remote", "add", "origin", bare.to_str().unwrap()], &seed);
git(&["push", "-q", "origin", "main"], &seed);
let bare_url = bare.to_str().unwrap().to_string();
std::fs::write(ws.join("api/health.hurl"), "GET https://new\n").unwrap();
std::fs::write(ws.join("api/orders.hurl"), "GET https://orders\n").unwrap();
std::fs::create_dir_all(ws.join(".git")).unwrap();
std::fs::write(ws.join(".git/config"), "secret\n").unwrap();
let origin = WorkspaceGitOrigin {
repo_url: bare_url.clone(),
commit_sha: "0000000000000000000000000000000000000000".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
};
let mut col = Collection::new("api-workspace".into(), Vec::new());
col.workspace_root = Some(ws.clone());
col.workspace_downloaded_from_git = true;
col.workspace_git_origin = Some(origin);
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.open_git_workspace_save_wizard();
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("the workspace git-save wizard should open")
};
assert!(
matches!(w.stage, GitSaveStage::Connect { .. }),
"opens on the Connect step"
);
assert_eq!(w.target_name.text(), "main");
w.stage = GitSaveStage::CommitMessage;
app.overlay = Some(Overlay::GitSave(w));
press(&mut app, KeyCode::Enter);
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("wizard should still be open (Pushing)")
};
assert!(
matches!(w.stage, GitSaveStage::Pushing),
"a workspace with files pushes immediately"
);
let rx = w.rx.take().expect("the push should have been spawned");
let msg = rx.recv().expect("the push thread should send a result");
let keep_open = app.apply_git_save_msg(&mut w, msg);
assert!(keep_open);
assert!(
matches!(w.stage, GitSaveStage::Done),
"a successful workspace push moves to Done"
);
assert!(matches!(app.status, Some(Status::GitSaved)));
let repinned = app.collections[ci].workspace_git_origin.as_ref().unwrap();
assert_ne!(
repinned.commit_sha, "0000000000000000000000000000000000000000",
"the origin sha is repinned to the new commit"
);
assert_eq!(repinned.ref_name, "main");
assert!(matches!(repinned.filter, WorkspaceGitFilter::HurlAndJson));
git(&["fetch", "-q", "origin", "main"], &seed);
let show = Command::new("git")
.current_dir(&seed)
.args(["show", "origin/main:api/health.hurl"])
.output()
.unwrap();
assert_eq!(String::from_utf8_lossy(&show.stdout), "GET https://new\n");
let orders = Command::new("git")
.current_dir(&seed)
.args(["show", "origin/main:api/orders.hurl"])
.output()
.unwrap();
assert!(orders.status.success(), "the new file was committed too");
let tree = Command::new("git")
.current_dir(&seed)
.args(["ls-tree", "-r", "--name-only", "origin/main"])
.output()
.unwrap();
let names = String::from_utf8_lossy(&tree.stdout);
assert!(
!names.contains(".git"),
"the workspace's internal .git folder is never committed"
);
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn saving_a_workspace_to_git_is_rejected_when_the_tab_was_not_git_loaded() {
use crate::i18n::Status;
let mut app = TuiApp::default();
let col = Collection::new("plain".into(), Vec::new());
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
app.open_git_workspace_save_wizard();
assert!(
app.overlay.is_none(),
"no wizard opens without a git origin"
);
assert!(matches!(app.status, Some(Status::NoGitOrigin)));
}
fn workspace_tab_with_unsaved_edit() -> (TuiApp, usize, std::path::PathBuf) {
let root = std::env::temp_dir().join(format!(
"paperboy-ws-unsaved-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&root).unwrap();
let file = root.join("current.hurl");
std::fs::write(&file, "GET https://on-disk-placeholder\n").unwrap();
let mut entry = HurlEntry {
method: "GET".into(),
url: "https://edited-in-memory".into(),
..Default::default()
};
entry.modified = true;
let mut col = Collection::new("api-workspace".into(), vec![entry]);
col.workspace_root = Some(root.clone());
col.workspace_downloaded_from_git = true;
col.path = Some(file.clone());
col.workspace_git_origin = Some(WorkspaceGitOrigin {
repo_url: "https://example.test/repo.git".into(),
commit_sha: "abc123".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
filter: WorkspaceGitFilter::HurlAndJson,
});
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
(app, ci, file)
}
#[test]
fn saving_a_workspace_to_git_warns_first_when_the_loaded_file_has_unsaved_edits() {
let (mut app, ci, _file) = workspace_tab_with_unsaved_edit();
app.open_git_workspace_save_wizard();
assert!(
matches!(
&app.overlay,
Some(Overlay::WorkspaceGitSaveUnsaved { ci: c, sel: 0 }) if *c == ci
),
"unsaved in-memory edits raise the warning instead of opening the wizard"
);
}
#[test]
fn workspace_unsaved_warning_save_choice_writes_the_file_then_opens_the_wizard() {
let (mut app, ci, file) = workspace_tab_with_unsaved_edit();
let expected = app.collections[ci].to_hurl();
app.open_git_workspace_save_wizard();
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::GitSave(_))),
"after saving, the git-save wizard opens"
);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
expected,
"the in-memory edits were written to disk"
);
assert!(
!app.collections[ci].entries[0].modified,
"the modified marker is cleared once saved"
);
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
#[test]
fn workspace_unsaved_warning_discard_choice_pushes_disk_version_and_keeps_edits_in_memory() {
let (mut app, ci, file) = workspace_tab_with_unsaved_edit();
let on_disk_before = std::fs::read_to_string(&file).unwrap();
app.open_git_workspace_save_wizard();
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::GitSave(_))),
"discarding opens the wizard directly (pushes the on-disk tree)"
);
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
on_disk_before,
"the on-disk file is left untouched"
);
assert!(
app.collections[ci].entries[0].modified,
"the in-memory edits remain (only left out of this push)"
);
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
#[test]
fn workspace_unsaved_warning_cancel_choice_closes_without_opening_the_wizard() {
let (mut app, _ci, file) = workspace_tab_with_unsaved_edit();
app.open_git_workspace_save_wizard();
press(&mut app, KeyCode::Up); press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none(), "cancel closes the warning");
app.open_git_workspace_save_wizard();
assert!(matches!(
app.overlay,
Some(Overlay::WorkspaceGitSaveUnsaved { .. })
));
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none(), "Esc dismisses the warning");
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
#[test]
fn saving_a_workspace_to_git_skips_the_warning_when_there_are_no_unsaved_edits() {
let (mut app, ci, file) = workspace_tab_with_unsaved_edit();
app.collections[ci].entries[0].modified = false;
app.open_git_workspace_save_wizard();
assert!(
matches!(app.overlay, Some(Overlay::GitSave(_))),
"with nothing unsaved the wizard opens straight away"
);
let _ = std::fs::remove_dir_all(file.parent().unwrap());
}
#[test]
fn saving_to_a_tag_that_already_exists_is_rejected_and_never_overwritten() {
use crate::git_remote::{GitOrigin, RefKind};
use std::process::Command;
fn git(args: &[&str], cwd: &std::path::Path) {
let out = Command::new("git")
.current_dir(cwd)
.args(args)
.output()
.expect("git should run");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let base = std::env::temp_dir().join(format!(
"paperboy-git-save-tag-test-{}-{nanos}",
std::process::id()
));
let bare = base.join("bare.git");
let work = base.join("work");
std::fs::create_dir_all(&bare).unwrap();
std::fs::create_dir_all(&work).unwrap();
git(&["init", "--bare", "-q", "."], &bare);
git(&["init", "-q"], &work);
git(&["checkout", "-q", "-b", "main"], &work);
git(&["config", "user.name", "Seed"], &work);
git(&["config", "user.email", "seed@test"], &work);
std::fs::write(work.join("api.hurl"), "GET https://example.com\n").unwrap();
git(&["add", "-A"], &work);
git(&["commit", "-q", "-m", "seed"], &work);
git(&["remote", "add", "origin", bare.to_str().unwrap()], &work);
git(&["push", "-q", "origin", "main"], &work);
git(&["tag", "v1.0"], &work);
git(&["push", "-q", "origin", "v1.0"], &work);
let bare_url = bare.to_str().unwrap().to_string();
let mut app = TuiApp::default();
let mut col = Collection::new("api".into(), Vec::new());
col.git_origin = Some(GitOrigin {
repo_url: bare_url.clone(),
path: "api.hurl".into(),
ref_kind: RefKind::Branch,
ref_name: "main".into(),
});
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.open_git_save_wizard();
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("wizard should be open")
};
w.include_env = false;
w.target_kind = GitSaveTarget::Tag;
w.target_name = super::editor::Editor::new("v1.0", false);
w.target_intent = TargetIntent::NewRef;
w.stage = GitSaveStage::CommitMessage;
app.overlay = Some(Overlay::GitSave(w));
press(&mut app, KeyCode::Enter);
let Some(Overlay::GitSave(mut w)) = app.overlay.take() else {
panic!("wizard should still be open (Pushing)")
};
let rx = w.rx.take().expect("the push should have been spawned");
let msg = rx.recv().expect("the push thread should send a result");
app.apply_git_save_msg(&mut w, msg);
match &w.stage {
GitSaveStage::Error(e) => assert!(
e.contains("already exists") || e.contains("never"),
"got: {e}"
),
_ => panic!("expected the tag-exists rejection, got a different stage"),
}
assert_eq!(
app.collections[ci].git_origin.as_ref().unwrap().ref_name,
"main"
);
let _ = std::fs::remove_dir_all(&base);
}
fn buffer_text(buf: &ratatui::buffer::Buffer) -> String {
let area = buf.area();
let mut out = String::new();
for y in 0..area.height {
for x in 0..area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn flattened_content(buf: &ratatui::buffer::Buffer) -> String {
buffer_text(buf)
.chars()
.filter(|c| !"\n│─┌┐└┘\u{2588}".contains(*c))
.collect()
}
fn fg_at_substr(buf: &ratatui::buffer::Buffer, needle: &str) -> Option<ratatui::style::Color> {
let area = *buf.area();
let chars: Vec<String> = needle.chars().map(|c| c.to_string()).collect();
for y in 0..area.height {
for x in 0..area.width {
let matches = chars.iter().enumerate().all(|(k, ch)| {
let xx = x + k as u16;
xx < area.width && buf[(xx, y)].symbol() == ch
});
if matches {
return Some(buf[(x, y)].fg);
}
}
}
None
}
#[test]
fn env_panel_marks_modified_and_prompt_masks_the_secret() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = app_with_resolved_secret("s3cr3t");
only_env_mut(&mut app).vars[0].set_user_value_secrecy("changed-secret".into(), true, 0);
let mut term = Terminal::new(TestBackend::new(50, 8)).unwrap();
let popup = EnvPopupState::new(only_env_id(&app));
term.draw(|f| super::draw::draw_env_popup(f, &app, &popup, &s, &th))
.unwrap();
let panel = buffer_text(term.backend().buffer());
assert!(
panel.contains('\u{270e}'),
"modified var shows the pencil marker:\n{panel}"
);
assert!(
!panel.contains("changed-secret"),
"secret value never shown in the panel:\n{panel}"
);
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter);
let mut term2 = Terminal::new(TestBackend::new(72, 8)).unwrap();
term2
.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let overlay = buffer_text(term2.backend().buffer());
assert!(
!overlay.contains("changed-secret"),
"current secret hidden while editing:\n{overlay}"
);
assert!(
!overlay.contains("s3cr3t"),
"original secret hidden while editing:\n{overlay}"
);
assert!(
overlay.contains('\u{2022}'),
"the value is masked with bullets:\n{overlay}"
);
}
#[test]
fn r_reloads_only_a_failed_env_var_and_leaves_others_alone() {
let mut app = TuiApp::default();
let (mut env, _pending) =
crate::environment::parse_vars_pending("e".into(), "TOKEN={{ op://V/i/f }}\nPLAIN=hello");
env.apply_update(&crate::environment::EnvUpdate {
env_id: env.id,
index: 0,
value: None,
});
assert!(env.vars[0].is_failed(), "TOKEN should start out failed");
add_global_env(&mut app, env);
open_only_env_popup(&mut app);
app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
let env = only_env(&app);
assert!(
env.vars[0].loading,
"'r' should re-mark the failed var as loading while it retries"
);
assert!(
!env.vars[0].is_failed(),
"no longer counted as failed once a retry is in flight"
);
assert_eq!(
env.vars[1].value, "hello",
"the unrelated PLAIN var must be untouched"
);
assert_eq!(
app.pending_env.len(),
1,
"a background resolution should have been spawned for the retry"
);
assert!(
matches!(app.status, Some(crate::i18n::Status::EnvVarReloading(ref k)) if k == "TOKEN"),
"the status bar should confirm which variable is being retried"
);
}
#[test]
fn r_is_a_no_op_when_the_selected_var_did_not_fail() {
let mut app = app_with_resolved_secret("s3cr3t");
open_only_env_popup(&mut app);
app.on_key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert!(
app.pending_env.is_empty(),
"nothing to retry for an already-resolved variable"
);
assert!(
app.status.is_none(),
"no status message when there was nothing to reload"
);
}
#[test]
fn plain_r_no_longer_renames_a_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.focus = Pane::Main;
press(&mut app, KeyCode::Char('r'));
assert!(
app.overlay.is_none(),
"'r' must not open the rename prompt any more"
);
}
#[test]
fn f2_renames_the_active_non_builtin_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.focus = Pane::Main;
press(&mut app, KeyCode::F(2));
assert!(
matches!(
&app.overlay,
Some(Overlay::Prompt {
kind: PromptKind::RenameTab(1),
..
})
),
"F2 should open the rename prompt for the active tab"
);
}
#[test]
fn f2_does_nothing_on_the_builtin_request_tab() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::F(2));
assert!(
app.overlay.is_none(),
"the built-in Request tab can't be renamed"
);
}
#[test]
fn enter_on_the_tab_bar_renames_the_selected_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.focus = Pane::Tabs;
app.on_enter();
assert!(
matches!(
&app.overlay,
Some(Overlay::Prompt {
kind: PromptKind::RenameTab(1),
..
})
),
"Enter while browsing the tab bar should rename the selected (active) tab"
);
}
#[test]
fn enter_on_the_tab_bar_moves_focus_to_the_list_for_the_builtin_tab() {
let mut app = TuiApp {
focus: Pane::Tabs,
..Default::default()
};
app.on_enter();
assert!(
app.overlay.is_none(),
"the built-in Request tab can't be renamed"
);
assert!(
app.focus == Pane::List,
"Enter should fall back to moving focus into the list"
);
}
#[test]
fn n_key_adds_an_env_variable_in_the_env_pane_and_opens_new_request_elsewhere() {
let mut app = TuiApp::default();
let n = KeyCode::Char('n');
app.focus = Pane::List;
press(&mut app, n);
assert!(
matches!(app.overlay, Some(Overlay::NewRequest(_))),
"'n' opens New Request outside the Env pane"
);
app.overlay = None;
let env_id = add_empty_global_env(&mut app, "e");
app.focus = Pane::GlobalEnv;
app.overlay = Some(Overlay::EnvPopup(EnvPopupState::new(env_id)));
press(&mut app, n);
assert!(
matches!(app.overlay, Some(Overlay::EnvVarForm(ref f)) if f.env_id == env_id && !f.on_value),
"'n' opens the Key/Value add-variable form in the env popup"
);
}
#[test]
fn env_var_form_key_then_value_adds_the_variable() {
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.overlay = Some(Overlay::EnvPopup(EnvPopupState::new(env_id)));
press(&mut app, KeyCode::Char('n')); for ch in "API_TOKEN".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); for ch in "abc123".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::EnvPopup(_))),
"committing returns to the popup"
);
let env = only_env(&app);
assert_eq!(env.vars.len(), 1);
assert_eq!(env.vars[0].key, "API_TOKEN");
assert_eq!(env.vars[0].value, "abc123");
assert!(env.vars[0].user_added);
}
#[test]
fn adding_a_variable_creates_a_user_added_entry() {
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.add_env_var(env_id, " API_TOKEN ".into(), " abc123 ".into());
let env = only_env(&app);
assert_eq!(env.vars.len(), 1);
let v = &env.vars[0];
assert_eq!(v.key, "API_TOKEN");
assert_eq!(v.value, "abc123", "surrounding whitespace is trimmed");
assert!(v.user_added, "a hand-added variable is flagged user_added");
assert!(v.resolved);
}
#[test]
fn adding_a_variable_with_an_existing_key_replaces_it() {
let mut app = app_with_resolved_secret("s3cr3t"); app.add_env_var(only_env_id(&app), "TOKEN".into(), "plain".into());
let env = only_env(&app);
assert_eq!(
env.vars.len(),
1,
"a same-name entry is replaced, not duplicated"
);
assert_eq!(env.vars[0].value, "plain");
assert!(env.vars[0].user_added);
}
#[test]
fn adding_a_variable_ignores_an_empty_key() {
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.add_env_var(env_id, " ".into(), "x".into());
assert!(only_env(&app).vars.is_empty(), "an empty key is ignored");
}
#[test]
fn loading_an_environment_with_a_name_collision_opens_the_collision_popup() {
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.add_env_var(env_id, "KEEP".into(), "mine".into());
app.add_env_var(env_id, "SHARED".into(), "mine".into());
assert_eq!(
app.load_environment_text("e".into(), "SHARED=fromfile\nFROM_FILE=x\n", None, None),
None,
"loads are deferred until the collision is resolved"
);
assert!(
matches!(app.overlay, Some(Overlay::EnvCollision(_))),
"a same-name environment now opens the collision picker instead of auto-merging"
);
}
#[test]
fn env_panel_marks_a_user_added_variable_with_a_plus() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.add_env_var(env_id, "MYVAR".into(), "hello".into());
let mut term = Terminal::new(TestBackend::new(50, 8)).unwrap();
let popup = EnvPopupState::new(env_id);
term.draw(|f| super::draw::draw_env_popup(f, &app, &popup, &s, &th))
.unwrap();
let panel = buffer_text(term.backend().buffer());
assert!(
panel.contains('\u{271a}'),
"user-added var shows the plus marker:\n{panel}"
);
assert!(
panel.contains("MYVAR"),
"the variable name is shown:\n{panel}"
);
}
#[test]
fn selected_env_row_keeps_the_status_dot_colour_visible() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let app = app_with_resolved_secret("s3cr3t");
let mut term = Terminal::new(TestBackend::new(50, 8)).unwrap();
let popup = EnvPopupState::new(only_env_id(&app));
term.draw(|f| super::draw::draw_env_popup(f, &app, &popup, &s, &th))
.unwrap();
let buf = term.backend().buffer();
let area = *buf.area();
let mut found = false;
for y in 0..area.height {
for x in 0..area.width {
if buf[(x, y)].symbol() == "\u{25cf}" {
assert_eq!(
buf[(x, y)].fg,
th.ok,
"selected row's status dot keeps its colour"
);
found = true;
}
}
}
assert!(found, "a status dot (●) should be rendered");
}
#[test]
fn x_deletes_the_selected_request_when_the_list_is_focused() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.collections[0]
.entries
.push(HurlEntry::from_fields("b", "GET", "http://h/b", vec![], ""));
app.collections[0].selected_entry = 0;
app.focus = Pane::List;
press(&mut app, KeyCode::Char('x'));
assert_eq!(
app.collections[0].entries.len(),
1,
"the selected request is deleted"
);
assert_eq!(
app.collections[0].entries[0].url, "http://h/b",
"the other request remains"
);
}
#[test]
fn u_restores_the_most_recently_deleted_request_when_the_list_is_focused() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.collections[0]
.entries
.push(HurlEntry::from_fields("b", "GET", "http://h/b", vec![], ""));
app.collections[0].selected_entry = 0;
app.focus = Pane::List;
press(&mut app, KeyCode::Char('x')); assert_eq!(app.collections[0].entries.len(), 1);
press(&mut app, KeyCode::Char('u'));
assert_eq!(
app.collections[0].entries.len(),
2,
"the deleted request came back"
);
assert_eq!(
app.collections[0].entries[0].url, "http://h/a",
"restored at its original index"
);
assert_eq!(
app.collections[0].selected_entry, 0,
"the restored request becomes selected"
);
}
#[test]
fn restoring_with_no_deleted_requests_is_a_no_op() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('u'));
assert!(app.collections[0].entries.is_empty());
}
#[test]
fn restored_requests_come_back_in_last_deleted_first_order() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.collections[0]
.entries
.push(HurlEntry::from_fields("b", "GET", "http://h/b", vec![], ""));
app.focus = Pane::List;
app.collections[0].selected_entry = 0;
app.delete_selected_request(); app.collections[0].selected_entry = 0;
app.delete_selected_request(); assert!(app.collections[0].entries.is_empty());
app.restore_deleted_request();
assert_eq!(
app.collections[0].entries[0].url, "http://h/b",
"most recently deleted comes back first"
);
app.restore_deleted_request();
assert!(
app.collections[0]
.entries
.iter()
.any(|e| e.url == "http://h/a"),
"the earlier deletion is restored too"
);
}
#[test]
fn u_reopens_the_closed_tab_instead_when_not_on_the_list_pane() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.focus = Pane::List;
app.collections[0].selected_entry = 0;
press(&mut app, KeyCode::Char('x')); assert!(app.collections[0].entries.is_empty());
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.focus = Pane::Tabs;
app.close_active_tab();
assert_eq!(app.collections.len(), 1);
press(&mut app, KeyCode::Char('u'));
assert_eq!(
app.collections.len(),
2,
"the closed tab came back, not the deleted request"
);
assert_eq!(app.collections[1].name, "api");
assert!(
app.collections[0].entries.is_empty(),
"the deleted request stays untouched since focus wasn't on the List pane"
);
}
#[test]
fn x_closes_the_collection_tab_when_not_on_the_list_pane() {
let mut app = TuiApp::default();
app.collections
.push(Collection::new("extra".into(), vec![]));
app.active_tab = 1;
app.focus = Pane::Tabs;
press(&mut app, KeyCode::Char('x'));
assert_eq!(app.collections.len(), 1, "the collection tab is closed");
assert_eq!(app.active_tab, 0);
}
#[test]
fn a_request_added_to_a_real_collection_is_marked_user_added() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
let mut form = NewReq::new(String::new(), vec!["Scratch".into(), "api".into()], 1, None);
form.url = super::editor::Editor::new("http://h/x", false);
app.submit_new_request(form);
assert!(
app.collections[1].entries[0].user_added,
"a request added to a real collection is marked"
);
let mut form0 = NewReq::new(String::new(), vec!["Scratch".into(), "api".into()], 0, None);
form0.url = super::editor::Editor::new("http://h/y", false);
app.submit_new_request(form0);
assert!(
!app.collections[0].entries[0].user_added,
"scratch-space requests are not marked"
);
}
#[test]
fn f2_with_an_empty_url_keeps_the_wizard_open_and_warns_instead_of_discarding() {
let mut app = TuiApp::default();
app.focus = Pane::List;
press(&mut app, KeyCode::Char('n'));
if let Some(Overlay::NewRequest(form)) = &mut app.overlay {
form.name = super::editor::Editor::new("draft", false);
} else {
panic!("wizard did not open");
}
press(&mut app, KeyCode::F(2));
match &app.overlay {
Some(Overlay::NewRequest(form)) => {
assert_eq!(form.focus, NewField::Url, "focus jumps to the URL field");
assert_eq!(form.name.text(), "draft", "typed fields are preserved");
}
_ => panic!("the wizard must stay open on an empty-URL submit"),
}
assert!(
matches!(app.status, Some(crate::i18n::Status::NewRequestUrlRequired)),
"a status hint explains why saving was blocked"
);
assert!(
app.collections[0].entries.is_empty(),
"nothing was saved to any collection"
);
if let Some(Overlay::NewRequest(form)) = &mut app.overlay {
form.url = super::editor::Editor::new("http://h/ok", false);
}
press(&mut app, KeyCode::F(2));
assert!(
app.overlay.is_none(),
"the wizard closes once the URL is valid"
);
assert_eq!(app.collections[0].entries.len(), 1);
assert_eq!(app.collections[0].entries[0].url, "http://h/ok");
}
#[test]
fn an_untitled_request_stays_at_the_root_instead_of_a_url_named_folder() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
let mut form = NewReq::new(String::new(), vec!["Scratch".into(), "api".into()], 1, None);
form.url = super::editor::Editor::new("http://host/path/x", false);
app.submit_new_request(form);
let entry = &app.collections[1].entries[0];
assert_eq!(entry.title, "", "an untitled request keeps an empty title");
assert!(
crate::tree::folder_of(&app.collections[1].entries, 0).is_empty(),
"it lives at the root of the folder tree, not in a URL-named folder"
);
}
#[test]
fn collection_list_shows_the_plus_for_a_user_added_request() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
let mut file_req = HurlEntry::from_fields("from-file", "GET", "http://h/file", vec![], "");
file_req.user_added = false;
let mut added = HurlEntry::from_fields("added", "POST", "http://h/added", vec![], "");
added.user_added = true;
app.collections[1].entries = vec![file_req, added];
app.active_tab = 1;
app.focus = Pane::List;
let mut term = Terminal::new(TestBackend::new(46, 8)).unwrap();
term.draw(|f| super::draw::draw_collection_left(f, f.area(), &app, 1, &s, &th))
.unwrap();
let panel = buffer_text(term.backend().buffer());
assert!(
panel.contains('\u{271a}'),
"a user-added request shows the plus marker:\n{panel}"
);
}
#[test]
fn response_panel_shows_assert_results_supplemental_to_status() {
use crate::hurl::AssertOutcome;
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
{
let ci = app.active_tab;
let col = &mut app.collections[ci];
col.entries.push(HurlEntry::default());
col.selected_entry = 0;
let entry = &mut col.entries[0];
entry.last_response = Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "{}".into(),
assert_results: vec![
AssertOutcome {
expr: "jsonpath \"$.status\" == \"ok\"".into(),
passed: true,
detail: String::new(),
},
AssertOutcome {
expr: "jsonpath \"$.via\" == \"oauth2\"".into(),
passed: false,
detail: "got \"bearer\"".into(),
},
],
..Default::default()
});
}
let mut term = Terminal::new(TestBackend::new(90, 12)).unwrap();
let ci = app.active_tab;
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(out.contains("200 OK"), "status still shown:\n{out}");
assert!(
out.contains("[Asserts]"),
"assert badge supplements the status:\n{out}"
);
assert!(out.contains("1/2"), "passed/total count shown:\n{out}");
assert!(
out.contains('\u{2713}'),
"a passing assert shows a check:\n{out}"
);
assert!(
out.contains('\u{2717}'),
"a failing assert shows a cross:\n{out}"
);
assert!(
out.contains("got"),
"the failing assert's actual value is shown:\n{out}"
);
}
#[test]
fn response_panel_shows_the_selected_entrys_own_response_not_the_last_entry_run() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
let ci = app.active_tab;
let col = &mut app.collections[ci];
col.entries = vec![
HurlEntry {
title: "first".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "first-body".into(),
..Default::default()
}),
..Default::default()
},
HurlEntry {
title: "second".into(),
last_response: Some(crate::http::ApiResponse {
status: 500,
status_text: "Internal Server Error".into(),
body: "second-body".into(),
..Default::default()
}),
..Default::default()
},
];
let mut term = Terminal::new(TestBackend::new(90, 12)).unwrap();
app.collections[ci].selected_entry = 0;
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("200 OK"),
"first entry's own status shown:\n{out}"
);
assert!(
out.contains("first-body"),
"first entry's own body shown:\n{out}"
);
assert!(
!out.contains("second-body"),
"second entry's body must not leak in:\n{out}"
);
app.collections[ci].selected_entry = 1;
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("500 Internal Server Error"),
"second entry's own status shown:\n{out}"
);
assert!(
out.contains("second-body"),
"second entry's own body shown:\n{out}"
);
assert!(
!out.contains("first-body"),
"first entry's body must not leak in:\n{out}"
);
}
#[test]
fn response_panel_wraps_long_lines_instead_of_truncating() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let long_line: String = (0..100).map(|i| format!("{i:03}")).collect();
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "long".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(long_line.as_str()),
..Default::default()
}),
..Default::default()
}];
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
assert!(
app.resp_max_scroll > 0,
"wrapping the long line should add scrollable rows"
);
app.focus = Pane::Response;
for _ in 0..app.resp_max_scroll {
app.nav(1);
}
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
let out = flattened_content(term.backend().buffer());
let tail = &long_line[long_line.len() - 12..];
assert!(
out.contains(tail),
"the tail of the long line must still be reachable (wrapped, not truncated):\n{out}"
);
assert!(
app.resp_max_scroll > 0,
"wrapping the long line should add scrollable rows"
);
}
#[test]
fn response_panel_scroll_stops_at_the_last_line() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let body: String = (0..40)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "many-lines".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
assert!(
app.resp_max_scroll > 0,
"content must be taller than the viewport for this test"
);
for _ in 0..200 {
app.nav(1);
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
}
assert_eq!(
app.resp_scroll, app.resp_max_scroll,
"scrolling stops once the last line is in view, it never scrolls past it"
);
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("line39"),
"the last line is reachable and visible:\n{out}"
);
}
#[test]
fn clicking_the_response_scrollbar_jumps_the_scroll_to_that_position() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let body: String = (0..200)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "many-lines".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let bar = app.resp_scrollbar_area;
assert!(
bar.height > 1,
"the scrollbar must actually render for this test"
);
assert_eq!(app.resp_scroll, 0);
let ev = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: bar.x,
row: bar.y + bar.height - 1,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev);
assert_eq!(
app.resp_scroll, app.resp_max_scroll,
"clicking the bottom of the track jumps straight there"
);
assert_eq!(app.scrollbar_drag, Some(Pane::Response));
assert!(app.text_selection.is_none());
let up = MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: bar.x + 3,
row: bar.y,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(up);
assert_eq!(
app.resp_scroll, 0,
"dragging to the top of the track scrolls back to the start"
);
let up_ev = MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: bar.x + 3,
row: bar.y,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(up_ev);
assert_eq!(
app.scrollbar_drag, None,
"releasing the mouse ends the scrollbar drag"
);
}
#[test]
fn clicking_the_main_panel_scrollbar_scrolls_the_request_json_panel() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let long_url = format!("https://example.test/{}", "x".repeat(500));
app.collections[ci].entries = vec![HurlEntry {
title: "long".into(),
url: long_url,
..Default::default()
}];
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let bar = app.main_scrollbar_area;
assert!(
bar.height > 1,
"the scrollbar must actually render for this test"
);
assert_eq!(app.main_scroll, 0);
let ev = MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: bar.x,
row: bar.y + bar.height - 1,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev);
assert_eq!(app.main_scroll, app.main_max_scroll);
assert_eq!(app.scrollbar_drag, Some(Pane::Main));
}
#[test]
fn ctrl_up_down_page_scrolls_the_response_panel_by_its_visible_height() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let body: String = (0..200)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "many-lines".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| super::draw::draw_response(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
assert!(
app.resp_max_scroll > 0,
"content must be taller than the viewport for this test"
);
let page = app.resp_text_area.height;
assert!(
page > 1,
"the visible body area must be more than one row for this test to be meaningful"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(
app.resp_scroll, page,
"Ctrl+Down scrolls down by exactly one page"
);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
app.resp_scroll, 0,
"Ctrl+Up scrolls back up by exactly one page"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(app.resp_scroll, 1, "plain Down still scrolls a single line");
}
#[test]
fn response_panel_shows_a_scrollbar_overlaid_on_the_border_outside_the_selectable_area() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let body: String = (0..40)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "many-lines".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let area = ratatui::layout::Rect::new(0, 0, 40, 10);
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| super::draw::draw_response(f, area, &mut app, ci, &s, &th))
.unwrap();
assert!(
app.resp_max_scroll > 0,
"content must be taller than the viewport for this test"
);
let text_area = app.resp_text_area;
let border_x = area.x + area.width - 1;
assert!(
border_x >= text_area.x + text_area.width,
"the scrollbar's border column must sit outside the selectable text area"
);
let buf = term.backend().buffer();
let mut saw_thumb_or_track = false;
for y in text_area.y..text_area.y + text_area.height {
let sym = buf[(border_x, y)].symbol();
if sym == "\u{2588}" || sym == "\u{2502}" {
saw_thumb_or_track = true;
}
assert_ne!(
sym, "e",
"no stray text should ever land on the border/scrollbar column"
);
}
assert!(
saw_thumb_or_track,
"a scrollbar track or thumb should be drawn once the body overflows"
);
}
#[test]
fn request_json_panel_wraps_long_lines_instead_of_truncating() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let token: String = (0..100).map(|i| format!("{i:03}")).collect();
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries =
vec![HurlEntry::from_fields("t", "GET", "http://h/x", vec![], "")];
app.collections[ci].request_json_buf = format!("{{\n \"token\": \"{token}\"\n}}");
app.collections[ci].request_json_for = Some(0);
let mut term = Terminal::new(TestBackend::new(40, 14)).unwrap();
term.draw(|f| super::draw::draw_collection_main(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
assert!(
app.main_max_scroll > 0,
"wrapping the long line should add scrollable rows"
);
app.focus = Pane::Main;
for _ in 0..app.main_max_scroll {
app.nav(1);
}
term.draw(|f| super::draw::draw_collection_main(f, f.area(), &mut app, ci, &s, &th))
.unwrap();
let out = flattened_content(term.backend().buffer());
let tail = &token[token.len() - 12..];
assert!(
out.contains(tail),
"the tail of the long token must still be reachable (wrapped, not truncated):\n{out}"
);
}
#[test]
fn request_json_panel_shows_a_scrollbar_overlaid_on_the_border_outside_the_selectable_area() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let token: String = (0..100).map(|i| format!("{i:03}")).collect();
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries =
vec![HurlEntry::from_fields("t", "GET", "http://h/x", vec![], "")];
app.collections[ci].request_json_buf = format!("{{\n \"token\": \"{token}\"\n}}");
app.collections[ci].request_json_for = Some(0);
let area = ratatui::layout::Rect::new(0, 0, 40, 14);
let mut term = Terminal::new(TestBackend::new(40, 14)).unwrap();
term.draw(|f| super::draw::draw_collection_main(f, area, &mut app, ci, &s, &th))
.unwrap();
assert!(
app.main_max_scroll > 0,
"wrapping the long line should add scrollable rows"
);
let text_area = app.main_text_area;
let border_x = area.x + area.width - 1;
assert!(
border_x >= text_area.x + text_area.width,
"the scrollbar's border column must sit outside the selectable text area"
);
let buf = term.backend().buffer();
let mut saw_thumb_or_track = false;
for y in text_area.y..text_area.y + text_area.height {
let sym = buf[(border_x, y)].symbol();
if sym == "\u{2588}" || sym == "\u{2502}" {
saw_thumb_or_track = true;
}
}
assert!(
saw_thumb_or_track,
"a scrollbar track or thumb should be drawn once the body overflows"
);
}
#[test]
fn mouse_drag_inside_the_response_panel_selects_scoped_text_and_paints_a_highlight() {
use crate::i18n::Language;
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "sel".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "first line here\nsecond".into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.resp_text_area;
assert!(
area.width > 0 && area.height > 1,
"the body must actually render for this test"
);
let ev = |kind, col_offset: u16| MouseEvent {
kind,
column: area.x + col_offset,
row: area.y,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev(MouseEventKind::Down(MouseButton::Left), 2));
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), 5));
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), 5));
let sel = app
.text_selection
.expect("dragging inside the response panel should start a selection");
assert_eq!(sel.pane, Pane::Response);
let text = super::selection::extract_text(
sel.anchor,
sel.cursor,
app.resp_wrap.as_ref().unwrap(),
None,
);
assert_eq!(
text.as_deref(),
Some("rst "),
"chars 2..=5 of \"first line here\""
);
assert!(
matches!(app.status, Some(crate::i18n::Status::Copied)),
"releasing a mouse-drag selection copies it and sets the status message"
);
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let select_bg = super::theme::theme(&Language::English).select_bg;
let buf = term.backend().buffer();
let selected_cell = buf.cell((area.x + 2, area.y)).unwrap();
assert_eq!(
selected_cell.bg, select_bg,
"a selected cell must use the app's own selection colour"
);
let outside_cell = buf.cell((area.x + 10, area.y)).unwrap();
assert_ne!(
outside_cell.bg, select_bg,
"cells outside the selection must be untouched"
);
}
#[test]
fn y_recopies_the_active_selection_without_clearing_it() {
let mut app = TuiApp {
text_selection: Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 2),
cursor: TextPos::new(0, 5),
}),
..TuiApp::default()
};
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(
app.text_selection.is_some(),
"`y` must not clear the selection it just copied"
);
}
#[test]
fn copying_a_selection_sets_the_copied_status_message() {
use crate::i18n::Status;
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "sel".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "first line here\nsecond line".into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
app.text_selection = Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 5),
});
assert!(app.status.is_none(), "no status before copying");
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(
matches!(app.status, Some(Status::Copied)),
"copying a selection sets a status bar message confirming it"
);
}
#[test]
fn y_without_an_active_selection_is_a_no_op() {
let mut app = TuiApp {
text_selection: None,
..TuiApp::default()
};
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(app.text_selection.is_none());
assert!(
app.status.is_none(),
"nothing was copied, so no status message is shown"
);
}
#[test]
fn whole_panel_text_returns_the_full_response_body_when_the_response_panel_has_focus() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "sel".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "first line here\nsecond line".into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = app
.whole_panel_text(Pane::Response)
.expect("response panel has content");
assert_eq!(text, "first line here\nsecond line");
assert!(
app.can_copy(),
"y should copy the whole response since the Response panel has focus"
);
}
#[test]
fn whole_panel_text_returns_the_full_request_json_when_the_main_panel_has_focus() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let entry = HurlEntry {
method: "GET".into(),
url: "http://example.com/path".into(),
..Default::default()
};
let json = crate::request::build_request_json(&entry);
app.collections[ci].entries = vec![entry];
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = app
.whole_panel_text(Pane::Main)
.expect("main panel has content");
assert_eq!(text, json);
assert!(
app.can_copy(),
"y should copy the whole request JSON since the Main panel has focus"
);
}
#[test]
fn main_panel_copy_uses_the_substituted_value_not_the_raw_template() {
use crate::environment::{EnvVar, Environment, ValueSource};
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let entry = HurlEntry {
method: "GET".into(),
url: "http://example.com/path".into(),
headers: vec![("X-Token".into(), "{{ TOKEN }}".into())],
..Default::default()
};
app.collections[ci].entries = vec![entry];
let env_id = add_global_env(
&mut app,
Environment {
id: 0,
name: "e".into(),
vars: vec![EnvVar {
key: "TOKEN".into(),
value: "secret123".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "secret123".into(),
modified: false,
user_added: false,
raw: String::new(),
}],
path: None,
git_origin: None,
},
);
app.collections[ci].linked_env_id = Some(env_id);
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = app
.whole_panel_text(Pane::Main)
.expect("main panel has content");
assert!(
text.contains("secret123"),
"whole-panel copy must contain the substituted value: {text}"
);
assert!(
!text.contains("{{ TOKEN }}"),
"whole-panel copy must not contain the raw template: {text}"
);
let area = app.main_text_area;
let row_idx = (0..area.height)
.find(|&r| {
app.main_wrap
.as_ref()
.map(|w| {
w.line_text((app.main_scroll + r) as usize)
.contains("secret123")
})
.unwrap_or(false)
})
.expect("a visible row must show the substituted header value");
let ev = |kind, col_offset: u16| MouseEvent {
kind,
column: area.x + col_offset,
row: area.y + row_idx,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev(MouseEventKind::Down(MouseButton::Left), 0));
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), 30));
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), 30));
let sel = app
.text_selection
.expect("dragging inside the Main panel should start a selection");
let selected = super::selection::extract_text(
sel.anchor,
sel.cursor,
app.main_wrap.as_ref().unwrap(),
None,
)
.expect("selection should extract text");
assert!(
selected.contains("secret123"),
"dragged selection must contain the substituted value: {selected}"
);
assert!(
!selected.contains("{{ TOKEN }}"),
"dragged selection must not contain the raw template: {selected}"
);
}
#[test]
fn main_panel_copy_excludes_the_shadow_icon_but_keeps_other_exclamation_marks() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let entry = HurlEntry {
method: "GET".into(),
url: "http://example.com/path!important".into(),
headers: vec![("X-Token".into(), "{{ TOKEN }}".into())],
..Default::default()
};
app.collections[ci].entries = vec![entry];
let (active, _) = crate::environment::parse_vars_pending("active".into(), "TOKEN=from-active");
let active_id = add_global_env(&mut app, active);
app.active_env_id = Some(active_id);
let (linked, _) = crate::environment::parse_vars_pending("linked".into(), "TOKEN=secret123");
let linked_id = add_global_env(&mut app, linked);
app.collections[ci].linked_env_id = Some(linked_id);
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
assert!(
!app.main_shadow_icon_positions.is_empty(),
"TOKEN is defined in both envs, so a shadow icon must have been recorded"
);
let text = app
.whole_panel_text(Pane::Main)
.expect("main panel has content");
assert!(
text.contains("secret123"),
"the shadowed (linked) value must still be copied: {text}"
);
assert!(
!text.contains("!secret123"),
"the shadow icon glued to the value must not be copied: {text}"
);
assert!(
text.contains("path!important"),
"an unrelated, legitimate '!' must still be copied intact: {text}"
);
let area = app.main_text_area;
let row_idx = (0..area.height)
.find(|&r| {
app.main_wrap
.as_ref()
.map(|w| {
w.line_text((app.main_scroll + r) as usize)
.contains("secret123")
})
.unwrap_or(false)
})
.expect("a visible row must show the substituted header value");
let ev = |kind, col_offset: u16| MouseEvent {
kind,
column: area.x + col_offset,
row: area.y + row_idx,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev(MouseEventKind::Down(MouseButton::Left), 0));
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), 30));
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), 30));
let sel = app
.text_selection
.expect("dragging inside the Main panel should start a selection");
let selected = app
.concatenated_selection_text()
.expect("selection should extract text");
assert!(
selected.contains("secret123"),
"dragged selection must contain the shadowed value: {selected}"
);
assert!(
!selected.contains("!secret123"),
"dragged selection must not contain the shadow icon: {selected}"
);
let _ = sel;
}
#[test]
fn main_panel_shows_and_copies_hurl_text_when_the_default_view_is_hurl() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp {
default_request_view: RequestView::Hurl,
..Default::default()
};
let ci = app.active_tab;
let entry = HurlEntry {
method: "GET".into(),
url: "http://example.com/path".into(),
..Default::default()
};
let hurl = entry.to_hurl();
let json = crate::request::build_request_json(&entry);
app.collections[ci].entries = vec![entry];
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(100, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let buf = term.backend().buffer().clone();
let text = buffer_text(&buf);
assert!(
text.contains("Request Hurl"),
"the panel title switches to the Hurl-view label:\n{text}"
);
assert!(
text.contains("GET http://example.com/path"),
"the panel renders Hurl text:\n{text}"
);
let copied = app
.whole_panel_text(Pane::Main)
.expect("main panel has content");
assert_eq!(
copied, hurl,
"whole-panel copy uses the Hurl text, not the JSON, when the Hurl view is active"
);
assert_ne!(
copied, json,
"sanity check: the JSON and Hurl forms actually differ here"
);
}
#[test]
fn can_copy_is_false_with_no_selection_and_focus_on_a_panel_with_no_copyable_content() {
let app = TuiApp {
focus: Pane::List,
..TuiApp::default()
};
assert!(!app.can_copy());
let app = TuiApp {
focus: Pane::Response,
..TuiApp::default()
};
assert!(!app.can_copy(), "no response has been received yet");
}
#[test]
fn y_with_no_selection_copies_the_whole_focused_panel_without_creating_a_selection() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "sel".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "whole response body".into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(app.text_selection.is_none());
assert!(app.extra_selections.is_empty());
}
#[test]
fn main_panel_drag_extracts_the_expected_text() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let entry = HurlEntry {
method: "GET".into(),
url: "http://example.com/very/long/path/for/testing".into(),
..Default::default()
};
let json = crate::request::build_request_json(&entry);
let second_line = json
.lines()
.nth(1)
.expect("request JSON has at least 2 lines");
app.collections[ci].entries = vec![entry];
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.main_text_area;
assert!(
area.width > 0 && area.height > 1,
"main text area must render"
);
assert_eq!(
app.main_wrap.as_ref().map(|w| w.line_text(0)),
Some("{"),
"the JSON body's opening brace must be the first visible row"
);
let ev = |kind, col_offset: u16| MouseEvent {
kind,
column: area.x + col_offset,
row: area.y + 1,
modifiers: KeyModifiers::NONE,
};
let end_col = (second_line.chars().count() as u16).saturating_sub(1);
app.on_mouse(ev(MouseEventKind::Down(MouseButton::Left), 0));
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), end_col));
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), end_col));
let sel = app
.text_selection
.expect("dragging inside the Main panel should start a selection");
assert_eq!(sel.pane, Pane::Main);
let text = super::selection::extract_text(
sel.anchor,
sel.cursor,
app.main_wrap.as_ref().unwrap(),
None,
);
assert_eq!(
text.as_deref(),
Some(second_line),
"the whole second JSON line must be selected"
);
}
#[test]
fn dragging_past_the_bottom_edge_autoscrolls_and_extends_the_selection() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let body: String = (0..200)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
app.collections[ci].entries = vec![HurlEntry {
title: "scrollable".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 30)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.resp_text_area;
assert!(
area.height > 1,
"the body must actually render for this test"
);
assert!(
app.resp_max_scroll > 0,
"content must be taller than the viewport for this test"
);
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x,
row: area.y,
modifiers: KeyModifiers::NONE,
});
let below_row = area.y + area.height; app.on_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: area.x,
row: below_row,
modifiers: KeyModifiers::NONE,
});
assert!(
app.pending_autoscroll.is_some(),
"dragging past the bottom edge must arm auto-scroll"
);
assert_eq!(
app.resp_scroll, 1,
"the single drag event already ticks auto-scroll once"
);
let cursor_after_one_tick = app.text_selection.unwrap().cursor;
assert!(
cursor_after_one_tick.line > 0,
"the selection must already extend past the first line"
);
for _ in 0..5 {
app.autoscroll_tick();
}
assert_eq!(
app.resp_scroll, 6,
"each tick scrolls one more row while still held past the edge"
);
let cursor_after_more_ticks = app.text_selection.unwrap().cursor;
assert!(
cursor_after_more_ticks.line > cursor_after_one_tick.line,
"continued auto-scroll ticks keep extending the selection further down"
);
app.on_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: area.x,
row: area.y,
modifiers: KeyModifiers::NONE,
});
assert!(
app.pending_autoscroll.is_none(),
"returning inside the panel cancels auto-scroll"
);
}
#[test]
fn dragging_past_the_edge_at_the_very_top_or_bottom_selects_the_whole_boundary_line() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let body: String = (0..200)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
app.collections[ci].entries = vec![HurlEntry {
title: "scrollable".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 30)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.resp_text_area;
let max_scroll = app.resp_max_scroll;
assert!(
max_scroll > 0,
"content must be taller than the viewport for this test"
);
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x,
row: area.y,
modifiers: KeyModifiers::NONE,
});
app.pending_autoscroll = Some((Pane::Response, 1));
for _ in 0..(max_scroll as usize + 5) {
app.autoscroll_tick();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
assert_eq!(
app.resp_scroll, max_scroll,
"scrolling must stop once the last line is in view"
);
let sel = app.text_selection.expect("selection must still be active");
let wrap = app.resp_wrap.as_ref().unwrap();
let last_line = wrap.line_count() - 1;
assert_eq!(
sel.cursor.line, last_line,
"held past the bottom, the cursor must sit on the very last line"
);
let text = super::selection::extract_text(sel.anchor, sel.cursor, wrap, None)
.expect("selection has text");
assert!(
text.ends_with(&format!("line{}", 199)),
"the entire last line must be included in the selection: {text:?}"
);
app.on_mouse(MouseEvent {
kind: MouseEventKind::Up(MouseButton::Left),
column: area.x,
row: area.y,
modifiers: KeyModifiers::NONE,
});
app.resp_scroll = max_scroll;
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: area.x,
row: area.y + area.height - 1,
modifiers: KeyModifiers::NONE,
});
app.pending_autoscroll = Some((Pane::Response, -1));
for _ in 0..(max_scroll as usize + 5) {
app.autoscroll_tick();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
assert_eq!(
app.resp_scroll, 0,
"scrolling must stop once the first line is in view"
);
let sel = app.text_selection.expect("selection must still be active");
assert_eq!(
sel.cursor.line, 0,
"held past the top, the cursor must sit on the very first line"
);
assert_eq!(
sel.cursor.col, 0,
"held past the top, the cursor must sit at the very start of that line"
);
let wrap = app.resp_wrap.as_ref().unwrap();
let text = super::selection::extract_text(sel.anchor, sel.cursor, wrap, None)
.expect("selection has text");
assert!(
text.starts_with("line0"),
"the entire first line must be included in the selection: {text:?}"
);
}
#[test]
fn dragging_from_main_panel_past_its_bottom_edge_into_the_response_panel_does_not_panic() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let headers: Vec<(String, String)> = (0..40)
.map(|i| (format!("X-Header-{i}"), "v".repeat(300)))
.collect();
app.collections[ci].entries = vec![HurlEntry {
title: "tall-request".into(),
headers,
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from("some response body"),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let main_area = app.main_text_area;
assert!(main_area.height > 1, "the Main panel must actually render");
assert!(
app.main_max_scroll > 0,
"the request JSON must be taller than the viewport for this test"
);
let resp_area = app.resp_text_area;
assert!(
resp_area.y >= main_area.y + main_area.height,
"the Response panel must sit below the Main panel"
);
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: main_area.x,
row: main_area.y,
modifiers: KeyModifiers::NONE,
});
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
for row in
(main_area.y + main_area.height)..resp_area.y.saturating_add(resp_area.height).min(24)
{
app.on_mouse(MouseEvent {
kind: MouseEventKind::Drag(MouseButton::Left),
column: main_area.x,
row,
modifiers: KeyModifiers::NONE,
});
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
for _ in 0..10 {
app.autoscroll_tick();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
assert_eq!(
app.text_selection.map(|s| s.pane),
Some(Pane::Main),
"the selection must stay scoped to the Main panel it started in"
);
}
#[test]
fn shift_arrow_extends_the_selection_and_scrolls_it_into_view() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let body: String = (0..200)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
app.collections[ci].entries = vec![HurlEntry {
title: "scrollable".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 30)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
assert!(
app.resp_max_scroll > 0,
"content must be taller than the viewport for this test"
);
let start = TextPos::new(0, 2);
app.text_selection = Some(TextSelection {
pane: Pane::Response,
anchor: start,
cursor: start,
});
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
let after_down = app.text_selection.unwrap();
assert_eq!(
after_down.anchor, start,
"Shift+Down must not move the anchor"
);
assert_eq!(
after_down.cursor.line, 1,
"Shift+Down moves the cursor end down one logical line"
);
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
assert_eq!(
app.text_selection.unwrap().cursor.col,
3,
"Shift+Right moves the cursor end one char right"
);
let visible_rows = app.resp_text_area.height as usize;
for _ in 0..(visible_rows + 5) {
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT));
}
let far_cursor = app.text_selection.unwrap().cursor;
assert!(
far_cursor.line >= visible_rows,
"the selection cursor should have advanced well past the first screenful"
);
assert!(
app.resp_scroll > 0,
"scrolling down far enough via Shift+Down must scroll the panel to follow the cursor"
);
let wrap = app.resp_wrap.as_ref().unwrap();
let (row, _) = wrap.textpos_to_row_col(far_cursor);
assert!(
row >= app.resp_scroll as u32
&& row < app.resp_scroll as u32 + app.resp_text_area.height as u32,
"the moved cursor's row must be inside the scrolled-to viewport"
);
}
#[test]
fn resizing_the_panel_keeps_the_selection_on_the_same_characters() {
use crate::i18n::Language;
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let long_line: String = (0..40).map(|i| format!("{i:03}")).collect(); app.collections[ci].entries = vec![HurlEntry {
title: "resize".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(long_line.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 30)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let width_before = app.resp_text_area.width;
let sel = TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 10),
cursor: TextPos::new(0, 19),
};
app.text_selection = Some(sel);
let expected: String = long_line.chars().skip(10).take(10).collect();
let before_text = super::selection::extract_text(
sel.anchor,
sel.cursor,
app.resp_wrap.as_ref().unwrap(),
None,
);
assert_eq!(before_text.as_deref(), Some(expected.as_str()));
term.backend_mut().resize(30, 30);
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let width_after = app.resp_text_area.width;
assert_ne!(
width_before, width_after,
"the resize must actually change the panel's width for this test to be meaningful"
);
let after_text = super::selection::extract_text(
sel.anchor,
sel.cursor,
app.resp_wrap.as_ref().unwrap(),
None,
);
assert_eq!(
after_text.as_deref(),
Some(expected.as_str()),
"the selection must stay on the same characters after a resize, not the same terminal coordinates"
);
let buf = term.backend().buffer();
let wrap = app.resp_wrap.as_ref().unwrap();
let (row, col) = wrap.textpos_to_row_col(TextPos::new(0, 10));
let area = app.resp_text_area;
let screen_row = area.y + row as u16 - app.resp_scroll.min(row as u16);
let cell = buf.cell((area.x + col as u16, screen_row)).unwrap();
let select_bg = super::theme::theme(&Language::English).select_bg;
assert_eq!(
cell.bg, select_bg,
"the highlighted cell must follow the character to its new screen position after resize"
);
}
#[test]
fn clicking_outside_both_text_panels_clears_any_selection() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = TuiApp {
text_selection: Some(TextSelection {
pane: Pane::Main,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 0),
}),
..Default::default()
};
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert!(
app.text_selection.is_none(),
"clicking outside a text panel clears the selection"
);
}
#[test]
fn alt_click_drag_adds_a_region_instead_of_replacing_the_active_one() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "multi".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "first line here\nsecond line here".into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.resp_text_area;
let ev = |kind, col: u16, row: u16, modifiers: KeyModifiers| MouseEvent {
kind,
column: area.x + col,
row: area.y + row,
modifiers,
};
app.on_mouse(ev(
MouseEventKind::Down(MouseButton::Left),
0,
0,
KeyModifiers::NONE,
));
app.on_mouse(ev(
MouseEventKind::Drag(MouseButton::Left),
4,
0,
KeyModifiers::NONE,
));
app.on_mouse(ev(
MouseEventKind::Up(MouseButton::Left),
4,
0,
KeyModifiers::NONE,
));
assert!(
app.extra_selections.is_empty(),
"a single plain drag makes no extra regions yet"
);
app.on_mouse(ev(
MouseEventKind::Down(MouseButton::Left),
0,
1,
KeyModifiers::ALT,
));
app.on_mouse(ev(
MouseEventKind::Drag(MouseButton::Left),
5,
1,
KeyModifiers::ALT,
));
app.on_mouse(ev(
MouseEventKind::Up(MouseButton::Left),
5,
1,
KeyModifiers::ALT,
));
assert_eq!(
app.extra_selections.len(),
1,
"the first region is finalized, not discarded"
);
let wrap = app.resp_wrap.as_ref().unwrap();
let first_text = super::selection::extract_text(
app.extra_selections[0].anchor,
app.extra_selections[0].cursor,
wrap,
None,
);
assert_eq!(first_text.as_deref(), Some("first"));
let active = app
.text_selection
.expect("the Alt-drag becomes the new active region");
let second_text = super::selection::extract_text(active.anchor, active.cursor, wrap, None);
assert_eq!(second_text.as_deref(), Some("second"));
let combined = app.concatenated_selection_text();
assert_eq!(combined.as_deref(), Some("first\n\nsecond"));
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let select_bg = super::theme::theme(&crate::i18n::Language::English).select_bg;
let buf = term.backend().buffer();
assert_eq!(
buf.cell((area.x + 2, area.y)).unwrap().bg,
select_bg,
"extra region ('first') is painted"
);
assert_eq!(
buf.cell((area.x + 2, area.y + 1)).unwrap().bg,
select_bg,
"active region ('second') is painted"
);
}
#[test]
fn copying_multiple_regions_orders_by_document_position_not_creation_order() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp {
focus: Pane::Response,
extra_selections: vec![TextSelection {
pane: Pane::Response,
anchor: TextPos::new(1, 0),
cursor: TextPos::new(1, 6),
}],
text_selection: Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 5),
}),
..Default::default()
};
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "order".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: "start\nend body".into(),
..Default::default()
}),
..Default::default()
}];
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let combined = app.concatenated_selection_text();
assert_eq!(
combined.as_deref(),
Some("start\n\nend bod"),
"document-earlier region must come first, regardless of which was made first"
);
}
#[test]
fn a_plain_click_drag_clears_every_extra_region() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
let mut app = TuiApp {
extra_selections: vec![TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 3),
}],
text_selection: Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(1, 0),
cursor: TextPos::new(1, 3),
}),
..Default::default()
};
app.on_mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 0,
row: 0,
modifiers: KeyModifiers::NONE,
});
assert!(app.text_selection.is_none());
assert!(
app.extra_selections.is_empty(),
"a plain click clears every region, not just the active one"
);
}
#[test]
fn escape_clears_every_selection_region_and_y_is_a_no_op_afterwards() {
let mut app = TuiApp {
extra_selections: vec![TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 3),
}],
text_selection: Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(1, 0),
cursor: TextPos::new(1, 3),
}),
..Default::default()
};
press(&mut app, KeyCode::Esc);
assert!(app.text_selection.is_none());
assert!(
app.extra_selections.is_empty(),
"Escape clears every region, not just the active one"
);
assert_eq!(
app.concatenated_selection_text(),
None,
"nothing left to copy after clearing every region"
);
}
#[test]
fn escape_clears_an_active_text_selection() {
let mut app = TuiApp {
text_selection: Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 0),
}),
..Default::default()
};
press(&mut app, KeyCode::Esc);
assert!(
app.text_selection.is_none(),
"Escape dismisses an active selection"
);
}
#[test]
fn selecting_a_different_list_entry_clears_the_text_selection() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.collections[0]
.entries
.push(HurlEntry::from_fields("b", "GET", "http://h/b", vec![], ""));
app.focus = Pane::List;
app.text_selection = Some(TextSelection {
pane: Pane::Main,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 0),
});
app.nav(1);
assert!(
app.text_selection.is_none(),
"navigating to a different entry invalidates the old selection"
);
}
#[test]
fn switching_tabs_clears_the_text_selection() {
let mut app = TuiApp::default();
app.collections
.push(Collection::new("t".to_string(), vec![]));
app.text_selection = Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 0),
});
app.cycle_tab(true);
assert!(
app.text_selection.is_none(),
"switching tabs invalidates the old panel's selection"
);
}
#[test]
fn running_a_request_clears_the_text_selection() {
let mut app = app_in_main_pane();
let ci = app.active_tab;
app.text_selection = Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 0),
});
app.run_entry(ci);
assert!(
app.text_selection.is_none(),
"starting a new request invalidates any selection over the old response"
);
}
#[test]
fn environment_persists_in_source_form_without_leaking_secrets() {
let mut app = app_with_resolved_secret("s3cr3t");
app.add_env_var(only_env_id(&app), "TEAM".into(), "crabs".into());
let snapshot = app.to_persisted();
let json = serde_json::to_string(&snapshot).unwrap();
assert!(
json.contains("op://V/i/f"),
"the provider reference is persisted"
);
assert!(
!json.contains("s3cr3t"),
"the resolved secret is never written to disk"
);
let mut restored = TuiApp::default();
restored.apply_persisted(snapshot);
assert_eq!(
restored.collections[0].linked_env_id,
Some(only_env_id(&restored)),
"the collection relinks to the restored env"
);
let env = only_env(&restored);
let token = env
.vars
.iter()
.find(|v| v.key == "TOKEN")
.expect("TOKEN restored");
assert_eq!(token.raw, "{{ op://V/i/f }}", "the reference round-trips");
assert!(
token.is_pending(),
"the secret re-resolves on load, never read from disk"
);
let team = env
.vars
.iter()
.find(|v| v.key == "TEAM")
.expect("TEAM restored");
assert_eq!(team.value, "crabs");
assert!(team.user_added, "the hand-added marker survives a reload");
}
#[test]
fn saving_a_collection_clears_the_user_added_markers() {
let dir = temp_dir("savecol");
let path = dir.join("out.hurl");
let mut app = TuiApp::default();
let mut entry = HurlEntry::from_fields("new", "GET", "http://h/x", vec![], "");
entry.user_added = true;
app.collections
.push(Collection::new("api".into(), vec![entry]));
app.active_tab = 1;
app.do_file_action(FileAction::SaveCollection, path.to_str().unwrap());
assert!(path.exists(), "the collection was written");
assert!(
!app.collections[1].entries[0].user_added,
"the new marker is cleared after saving"
);
assert_eq!(
app.collections[1].path.as_deref(),
Some(path.as_path()),
"the save path is remembered"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn saving_an_environment_clears_new_and_modified_markers() {
let dir = temp_dir("saveenv");
let path = dir.join("out.vars");
let mut app = app_with_resolved_secret("s3cr3t");
app.add_env_var(only_env_id(&app), "TEAM".into(), "crabs".into()); app.commit_prompt_with_secrecy(
PromptKind::EnvValue(only_env_id(&app), 0),
"my-token".into(),
true,
);
{
let env = only_env(&app);
assert!(
env.vars.iter().any(|v| v.modified),
"a modified var exists before save"
);
assert!(
env.vars.iter().any(|v| v.user_added),
"a new var exists before save"
);
}
app.do_file_action(FileAction::SaveEnv, path.to_str().unwrap());
assert!(path.exists(), "the environment file was written");
let env = only_env(&app);
assert!(
env.vars.iter().all(|v| !v.user_added && !v.modified),
"all new/modified markers are cleared after saving",
);
for v in &env.vars {
assert_eq!(
v.original_value, v.value,
"the saved values become the new baseline"
);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tab_completes_the_hurl_ghost_in_a_save_prompt() {
let mut app = TuiApp::default();
app.open_path_prompt(FileAction::SaveCollection, "Save", "");
for ch in "myfile".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab);
match &app.overlay {
Some(Overlay::Prompt { editor, .. }) => assert_eq!(editor.text(), "myfile.hurl"),
_ => panic!("prompt not open"),
}
press(&mut app, KeyCode::Tab);
match &app.overlay {
Some(Overlay::Prompt { editor, .. }) => assert_eq!(editor.text(), "myfile.hurl"),
_ => panic!("prompt not open"),
}
}
#[test]
fn right_arrow_completes_the_vars_ghost_at_the_end() {
let mut app = TuiApp::default();
app.open_path_prompt(FileAction::SaveEnv, "Save", "");
for ch in "staging".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Right); match &app.overlay {
Some(Overlay::Prompt { editor, .. }) => assert_eq!(editor.text(), "staging.vars"),
_ => panic!("prompt not open"),
}
}
#[test]
fn save_prompt_renders_the_dimmed_extension_ghost() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
app.open_path_prompt(FileAction::SaveCollection, "Save", "");
for ch in "myfile".chars() {
press(&mut app, KeyCode::Char(ch));
}
let mut term = Terminal::new(TestBackend::new(72, 6)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("myfile.hurl"),
"the .hurl ghost is drawn after the filename:\n{out}"
);
}
#[test]
fn save_confirm_popup_substitutes_the_new_and_modified_counts() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = app_with_resolved_secret("s3cr3t");
app.add_env_var(0, "TEAM".into(), "crabs".into());
for i in 0..3 {
let mut e = HurlEntry::from_fields(&format!("r{i}"), "GET", "http://h/x", vec![], "");
e.user_added = true;
app.collections[0].entries.push(e);
}
app.overlay = Some(Overlay::Confirm {
action: ConfirmAction::Save(FileAction::SaveCollection),
sel: 1,
});
let mut term = Terminal::new(TestBackend::new(80, 12)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
!out.contains("{r}") && !out.contains("{e}"),
"placeholders are substituted:\n{out}"
);
assert!(out.contains('3'), "the request count is shown:\n{out}");
assert!(
out.contains("request"),
"the request count is labelled:\n{out}"
);
assert!(
!out.contains("environment"),
"the collection warning omits the environment:\n{out}"
);
assert!(
out.contains("Proceed?"),
"the confirmation asks to proceed:\n{out}"
);
}
#[test]
fn save_collection_to_original_confirms_then_saves() {
let dir = temp_dir("saveorig");
let path = dir.join("api.hurl");
std::fs::write(&path, "# seed\nGET http://h/x\nHTTP 200\n").unwrap();
let mut app = TuiApp::default();
let mut entry = HurlEntry::from_fields("new", "GET", "http://h/x", vec![], "");
entry.user_added = true;
let mut col = Collection::new("api".into(), vec![entry]);
col.path = Some(path.clone());
app.collections.push(col);
app.active_tab = 1;
press(&mut app, KeyCode::Char('f'));
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter);
press(&mut app, KeyCode::Enter);
assert!(
matches!(
app.overlay,
Some(Overlay::Confirm {
action: ConfirmAction::Save(FileAction::SaveCollection),
..
})
),
"a changed collection confirms before overwriting the original",
);
press(&mut app, KeyCode::Char('y')); assert!(
app.overlay.is_none(),
"saving to the original does not prompt for a name"
);
assert!(
!app.collections[1].entries[0].user_added,
"markers cleared after saving"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn saving_an_unchanged_collection_to_original_is_silent() {
let dir = temp_dir("saveunchanged");
let path = dir.join("api.hurl");
let mut app = TuiApp::default();
let mut col = Collection::new(
"api".into(),
vec![HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "")],
);
col.path = Some(path.clone());
app.collections.push(col);
app.active_tab = 1;
app.begin_save(FileAction::SaveCollection);
assert!(
app.overlay.is_none(),
"no warning when there are no changes to save"
);
assert!(
path.exists(),
"the collection is still written to the original file"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_collection_as_confirms_overwrite_of_existing_file() {
let dir = temp_dir("saveas");
let existing = dir.join("taken.hurl");
std::fs::write(&existing, "old contents").unwrap();
let mut app = TuiApp::default();
let mut entry = HurlEntry::from_fields("new", "GET", "http://h/x", vec![], "");
entry.user_added = true;
app.collections
.push(Collection::new("api".into(), vec![entry]));
app.active_tab = 1;
app.begin_save_as(FileAction::SaveCollection);
assert!(
matches!(
app.overlay,
Some(Overlay::Prompt {
kind: PromptKind::FilePath(FileAction::SaveCollection),
..
})
),
"Save As prompts for a name",
);
app.commit_prompt_with_secrecy(
PromptKind::FilePath(FileAction::SaveCollection),
existing.to_string_lossy().into_owned(),
true,
);
assert!(
matches!(
app.overlay,
Some(Overlay::Confirm {
action: ConfirmAction::Overwrite(FileAction::SaveCollection),
..
})
),
"saving over an existing file asks to confirm the overwrite",
);
press(&mut app, KeyCode::Char('y'));
assert!(
!std::fs::read_to_string(&existing)
.unwrap()
.contains("old contents"),
"file overwritten"
);
assert!(
!app.collections[1].entries[0].user_added,
"markers cleared after saving"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_collection_as_to_a_new_file_writes_without_confirmation() {
let dir = temp_dir("saveasnew");
let fresh = dir.join("fresh.hurl");
let mut app = TuiApp::default();
app.collections.push(Collection::new(
"api".into(),
vec![HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "")],
));
app.active_tab = 1;
app.begin_save_as(FileAction::SaveCollection);
app.commit_prompt_with_secrecy(
PromptKind::FilePath(FileAction::SaveCollection),
fresh.to_string_lossy().into_owned(),
true,
);
assert!(
!matches!(
app.overlay,
Some(Overlay::Confirm {
action: ConfirmAction::Overwrite(_),
..
})
),
"a brand-new file needs no overwrite confirmation",
);
assert!(fresh.exists(), "written to the new file");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn scratch_space_can_be_saved_as_a_collection() {
let dir = temp_dir("scratchsave");
let path = dir.join("scratch.hurl");
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("r", "GET", "http://h/x", vec![], ""));
app.begin_save(FileAction::SaveCollection);
assert!(
matches!(
app.overlay,
Some(Overlay::Prompt {
kind: PromptKind::FilePath(FileAction::SaveCollection),
..
})
),
"the Scratch Space is saveable (prompts for a name since it has no file yet)",
);
app.commit_prompt_with_secrecy(
PromptKind::FilePath(FileAction::SaveCollection),
path.to_string_lossy().into_owned(),
true,
);
assert!(
path.exists(),
"the Scratch Space is written to a collection file"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn collection_list_scroll_stops_when_the_name_end_is_in_view() {
let mut app = TuiApp::default();
app.collections[0].entries.push(HurlEntry::from_fields(
"r",
"GET",
"http://example.test/some/very/long/path/that/scrolls/off/the/edge",
vec![],
"",
));
app.collections[0].selected_entry = 0;
app.focus = Pane::List;
let url_len = app.collections[0].entries[0].url.chars().count();
app.list_scroll_w.set(20);
for _ in 0..100 {
app.scroll_list_h(4); }
assert_eq!(
app.list_hscroll,
(url_len - (20 - 1)) as u16,
"scrolling stops once the end of the name is visible (no blank overscroll)",
);
for _ in 0..100 {
app.scroll_list_h(-4); }
assert_eq!(app.list_hscroll, 0, "cannot scroll left of the start");
}
#[test]
fn environment_panel_scroll_clamps_to_the_selected_row() {
let mut app = TuiApp::default();
let env_id = add_empty_global_env(&mut app, "e");
app.add_env_var(
env_id,
"A_LONG_KEY_NAME".into(),
"a-long-value-that-scrolls-off-screen".into(),
);
app.overlay = Some(Overlay::EnvPopup(EnvPopupState::new(env_id)));
if let Some(Overlay::EnvPopup(popup)) = &mut app.overlay {
popup.scroll_w.set(10);
}
let len = {
let v = &only_env(&app).vars[0];
v.key.chars().count() + 3 + v.display_value().chars().count()
};
for _ in 0..100 {
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
}
match &app.overlay {
Some(Overlay::EnvPopup(popup)) => assert_eq!(
popup.hscroll,
(len - (10 - 1)) as u16,
"the env row scrolls only until its whole `key = value` is in view",
),
_ => panic!("env popup closed"),
}
for _ in 0..100 {
app.on_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
}
match &app.overlay {
Some(Overlay::EnvPopup(popup)) => {
assert_eq!(popup.hscroll, 0, "cannot scroll left of the start")
}
_ => panic!("env popup closed"),
}
}
#[test]
fn loading_a_postman_json_collection_imports_its_requests() {
let mut app = TuiApp::default();
let json = r#"{
"info": { "name": "pm", "schema": "https://schema.getpostman.com/..v2.1.0" },
"item": [
{ "name": "one", "request": { "method": "GET", "url": "{{url}}/one" } },
{ "name": "two", "request": { "method": "POST", "url": "{{url}}/two" } }
]
}"#;
let before = app.collections.len();
let ok = app.load_collection_text("pm".into(), json, None);
assert!(ok, "a Postman JSON export loads as a collection");
assert_eq!(
app.collections.len(),
before + 1,
"a new tab is added for the import"
);
let e = &app.collections[app.active_tab].entries;
assert_eq!(e.len(), 2, "both Postman requests are imported");
assert_eq!((e[0].title.as_str(), e[0].method.as_str()), ("one", "GET"));
assert_eq!(
(e[1].method.as_str(), e[1].url.as_str()),
("POST", "{{url}}/two")
);
}
#[test]
fn request_preview_substitutes_env_values_and_masks_secrets() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let _s = Strings::for_language(&Language::English);
let mut app = app_with_resolved_secret("supersecret");
app.add_env_var(
only_env_id(&app),
"BASE_URL".into(),
"http://127.0.0.1:8080".into(),
);
app.collections[0].entries.push(HurlEntry::from_fields(
"r",
"GET",
"{{ BASE_URL }}/x",
vec![("Authorization".into(), "Bearer {{ TOKEN }}".into())],
"",
));
app.collections[0].selected_entry = app.collections[0].entries.len() - 1;
app.focus = Pane::Main;
let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("http://127.0.0.1:8080/x"),
"BASE_URL is substituted in the preview:\n{out}"
);
assert!(
out.contains("\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"),
"a substituted secret shows as 8 dots:\n{out}"
);
assert!(
!out.contains("supersecret"),
"the real secret value is never shown:\n{out}"
);
assert!(
app.collections[0]
.request_json_buf
.contains("{{ BASE_URL }}"),
"the editor keeps the raw placeholder, not the substituted value",
);
assert!(
app.collections[0].request_json_buf.contains("{{ TOKEN }}"),
"the editor keeps the raw secret reference, never the resolved value",
);
}
#[test]
fn a_modified_request_shows_the_pencil_marker() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let _s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
let mut col = Collection::new(
"api".into(),
vec![HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "")],
);
col.entries[0].modified = true;
app.collections.push(col);
app.active_tab = 1;
app.focus = Pane::List;
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains('\u{270e}'),
"a modified request is marked with a pencil:\n{out}"
);
}
#[test]
fn collection_list_substitutes_and_colour_codes_by_status() {
use crate::environment::{EnvVar, Environment, ValueSource};
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let env = Environment {
id: 0,
name: "e".into(),
vars: vec![
EnvVar {
key: "HOST".into(),
value: "10.0.0.1".into(),
source: ValueSource::Literal,
resolved: true,
loading: false,
original_value: "10.0.0.1".into(),
modified: false,
user_added: false,
raw: String::new(),
},
EnvVar {
key: "BAD".into(),
value: "{{ op://x }}".into(),
source: ValueSource::OnePassword,
resolved: false,
loading: false,
original_value: "{{ op://x }}".into(),
modified: false,
user_added: false,
raw: String::new(),
},
],
path: None,
git_origin: None,
};
let mut col = Collection::new(
"api".into(),
vec![
HurlEntry::from_fields("sel", "GET", "http://plain/0", vec![], ""), HurlEntry::from_fields("a", "GET", "{{ HOST }}/a", vec![], ""),
HurlEntry::from_fields("b", "GET", "{{ BAD }}/b", vec![], ""),
],
);
let mut app = TuiApp::default();
let env_id = add_global_env(&mut app, env);
col.linked_env_id = Some(env_id);
app.collections.push(col);
app.active_tab = 1;
app.focus = Pane::List;
let mut term = Terminal::new(TestBackend::new(60, 16)).unwrap();
term.draw(|f| super::draw::draw_collection_left(f, f.area(), &app, 1, &s, &th))
.unwrap();
let buf = term.backend().buffer();
let out = buffer_text(buf);
assert!(
out.contains("10.0.0.1/a"),
"a loaded literal is substituted in the list:\n{out}"
);
assert!(
out.contains("{{ BAD }}/b"),
"a failed var keeps its placeholder in the list:\n{out}"
);
assert_eq!(
fg_at_substr(buf, "10.0.0.1"),
Some(th.subst),
"a literal substitution is cyan"
);
assert_eq!(
fg_at_substr(buf, "{{ BAD }}"),
Some(th.err),
"a failed substitution is red"
);
}
#[test]
fn editing_request_json_via_ctrl_enter_persists_and_marks_modified() {
use super::editor::Editor;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = TuiApp::default();
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
app.collections
.push(Collection::new("api".into(), vec![entry]));
app.active_tab = 1;
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some(), "raw mode editor should be open");
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
let new_text = editor.text().replace("http://h/x", "http://h/CHANGED");
*editor = Editor::new(&new_text, true);
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.overlay.is_none(), "editor should close on commit");
let entry = &app.collections[1].entries[0];
assert_eq!(
entry.url, "http://h/CHANGED",
"the edit should persist to the entry"
);
assert!(entry.modified, "the entry should be flagged modified");
assert_eq!(
app.changed_request_count(1),
1,
"save-count should reflect the modification"
);
}
#[test]
fn editing_request_json_via_f2_persists_and_marks_modified() {
use super::editor::Editor;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut app = TuiApp::default();
let entry = HurlEntry::from_fields(
"r",
"GET",
"http://h/x",
vec![("X-A".into(), "1".into())],
"",
);
app.collections
.push(Collection::new("api".into(), vec![entry]));
app.active_tab = 1;
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some());
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
let new_text = editor.text().replace("X-A: 1", "X-A: 2");
*editor = Editor::new(&new_text, true);
}
app.on_key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert!(app.overlay.is_none(), "F2 should commit and close");
let entry = &app.collections[1].entries[0];
assert_eq!(
entry.headers,
vec![("X-A".to_string(), "2".to_string())],
"the header edit should persist"
);
assert!(entry.modified, "F2 commit should also flag modified");
assert_eq!(app.changed_request_count(1), 1);
}
#[test]
fn opening_a_secret_prompt_defaults_the_still_secret_checkbox_to_checked() {
let mut app = app_with_resolved_secret("s3cr3t");
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::Prompt {
secret_checkbox, ..
}) => {
assert_eq!(
*secret_checkbox,
Some(true),
"secret-sourced vars show the checkbox, checked by default"
);
}
_ => panic!("expected the env-value prompt to open"),
}
}
#[test]
fn opening_a_plain_value_prompt_has_no_still_secret_checkbox() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("e".into(), "PLAIN=hello");
let env_id = add_global_env(&mut app, env);
app.collections[0].linked_env_id = Some(env_id);
app.overlay = Some(Overlay::EnvPopup(EnvPopupState::new(env_id)));
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::Prompt {
secret_checkbox, ..
}) => {
assert_eq!(
*secret_checkbox, None,
"plain literal vars never show the checkbox"
);
}
_ => panic!("expected the env-value prompt to open"),
}
}
#[test]
fn ctrl_t_toggles_the_still_secret_checkbox() {
let mut app = app_with_resolved_secret("s3cr3t");
open_only_env_popup(&mut app);
press(&mut app, KeyCode::Enter);
app.on_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
match &app.overlay {
Some(Overlay::Prompt {
secret_checkbox, ..
}) => {
assert_eq!(*secret_checkbox, Some(false), "Ctrl+T unchecks the box");
}
_ => panic!("prompt not open"),
}
app.on_key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL));
match &app.overlay {
Some(Overlay::Prompt {
secret_checkbox, ..
}) => {
assert_eq!(*secret_checkbox, Some(true), "a second Ctrl+T rechecks it");
}
_ => panic!("prompt not open"),
}
}
#[test]
fn committing_with_the_checkbox_checked_keeps_the_secret_in_memory_only() {
let mut app = app_with_resolved_secret("s3cr3t");
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(only_env_id(&app), 0),
"my-own-token".into(),
true,
);
let env = only_env(&app);
let v = &env.vars[0];
assert!(v.modified);
assert_eq!(
v.value, "my-own-token",
"the in-memory value reflects the edit"
);
assert!(v.is_secret_source(), "the var is still treated as secret");
assert!(
env.to_vars_text().contains("op://V/i/f"),
"the provider reference is still what gets persisted"
);
assert!(
!env.to_vars_text().contains("my-own-token"),
"the plaintext secret must never be persisted"
);
assert!(
app.has_unsaved_secret_changes(),
"an in-memory-only secret edit is an unsaved secret change"
);
}
#[test]
fn unchecking_still_secret_persists_the_plaintext_and_declassifies_the_var() {
let mut app = app_with_resolved_secret("s3cr3t");
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(only_env_id(&app), 0),
"no-longer-secret".into(),
false,
);
let env = only_env(&app);
let v = &env.vars[0];
assert!(v.modified);
assert_eq!(v.value, "no-longer-secret");
assert!(
!v.is_secret_source(),
"the var is declassified after an unchecked commit"
);
assert!(!v.is_secret(), "a declassified var is no longer masked");
assert_eq!(
v.display_value(),
"no-longer-secret",
"the value now displays in the clear"
);
assert!(
env.to_vars_text().contains("no-longer-secret"),
"the plaintext value now persists to the .vars source"
);
assert!(
!env.to_vars_text().contains("op://V/i/f"),
"the provider reference is replaced once declassified"
);
assert!(
!app.has_unsaved_secret_changes(),
"a declassified value is no longer flagged as an unsaved secret change"
);
}
#[test]
fn editing_a_literal_value_into_an_op_reference_reclassifies_and_queues_loading() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("e".into(), "BASE_URL=127.0.0.1");
let env_id = add_global_env(&mut app, env);
app.collections[0].linked_env_id = Some(env_id);
let before = app.pending_env.len();
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(env_id, 0),
"{{ op://Vault/item/field }}".into(),
true,
);
let v = &only_env(&app).vars[0];
assert_eq!(
v.source,
crate::environment::ValueSource::OnePassword,
"now classified as a 1Password ref"
);
assert!(
v.loading && !v.resolved,
"queued for background resolution, not yet resolved"
);
assert_eq!(
v.raw, "{{ op://Vault/item/field }}",
"the reference itself is what gets persisted"
);
assert_eq!(
app.pending_env.len(),
before + 1,
"a resolution job was queued"
);
}
#[test]
fn editing_a_literal_value_into_an_ssm_reference_reclassifies_and_queues_loading() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("e".into(), "BASE_URL=127.0.0.1");
let env_id = add_global_env(&mut app, env);
app.collections[0].linked_env_id = Some(env_id);
let before = app.pending_env.len();
app.commit_prompt_with_secrecy(
PromptKind::EnvValue(env_id, 0),
"{{ ssm:/demo/param }}".into(),
true,
);
let v = &only_env(&app).vars[0];
assert_eq!(
v.source,
crate::environment::ValueSource::Ssm,
"now classified as an SSM ref"
);
assert!(
v.loading && !v.resolved,
"queued for background resolution, not yet resolved"
);
assert_eq!(
app.pending_env.len(),
before + 1,
"a resolution job was queued"
);
}
#[test]
fn editing_a_plain_value_to_another_plain_value_stays_literal_with_no_pending_work() {
let mut app = TuiApp::default();
let (env, _) = crate::environment::parse_vars_pending("e".into(), "BASE_URL=127.0.0.1");
let env_id = add_global_env(&mut app, env);
app.collections[0].linked_env_id = Some(env_id);
let before = app.pending_env.len();
app.commit_prompt_with_secrecy(PromptKind::EnvValue(env_id, 0), "10.0.0.1".into(), true);
let v = &only_env(&app).vars[0];
assert_eq!(v.source, crate::environment::ValueSource::Literal);
assert!(v.resolved && !v.loading);
assert_eq!(
app.pending_env.len(),
before,
"no resolution job queued for a plain literal edit"
);
}
#[test]
fn enter_on_the_requests_list_opens_the_edit_request_wizard() {
let mut app = TuiApp::default();
app.collections[0]
.entries
.push(HurlEntry::from_fields("a", "GET", "http://h/a", vec![], ""));
app.focus = Pane::List;
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.focus, Pane::Main),
"focus moves to the request panel, as before"
);
match &app.overlay {
Some(Overlay::NewRequest(form)) => {
assert_eq!(
form.editing,
Some((0, 0)),
"the wizard is prefilled for the selected entry"
);
assert_eq!(form.name.text(), "a");
assert_eq!(form.url.text(), "http://h/a");
assert!(
form.asserts.is_empty(),
"no default blank assert row when the entry has none"
);
assert!(
form.captures.is_empty(),
"no default blank capture row when the entry has none"
);
}
_ => panic!("expected the Edit Request wizard to open"),
}
}
#[test]
fn enter_on_an_empty_requests_list_does_not_open_an_editor() {
let mut app = TuiApp {
focus: Pane::List,
..Default::default()
};
press(&mut app, KeyCode::Enter);
assert!(matches!(app.focus, Pane::Main));
assert!(
app.overlay.is_none(),
"there is nothing to edit on an empty list"
);
}
#[test]
fn editing_a_request_preserves_fields_the_wizard_does_not_expose() {
let mut app = TuiApp::default();
let mut entry = HurlEntry::from_fields("orig", "GET", "http://h/x", vec![], "");
entry.query_params = vec![("q".into(), "1".into())];
entry.basic_auth = Some(("user".into(), "pass".into()));
entry.expected_status = Some(200);
app.collections[0].entries.push(entry);
app.focus = Pane::List;
press(&mut app, KeyCode::Enter); match &mut app.overlay {
Some(Overlay::NewRequest(form)) => {
form.name = super::editor::Editor::new("renamed", false);
}
_ => panic!("expected the Edit Request wizard to open"),
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries[0];
assert_eq!(e.title, "renamed", "the edited field is applied");
assert!(e.modified, "the entry is flagged modified");
assert_eq!(
e.query_params,
vec![("q".to_string(), "1".to_string())],
"query params untouched"
);
assert_eq!(
e.basic_auth,
Some(("user".to_string(), "pass".to_string())),
"basic auth untouched"
);
assert_eq!(e.expected_status, Some(200), "expected status untouched");
}
#[test]
fn creating_a_request_with_an_assert_via_the_table() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "status == 200".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(e[0].asserts, vec!["status == 200".to_string()]);
}
#[test]
fn creating_a_request_with_a_capture_via_the_table() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "token".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); for ch in "jsonpath \"$.token\"".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(
e[0].captures,
vec![("token".to_string(), "jsonpath \"$.token\"".to_string())]
);
}
#[test]
fn a_new_request_starts_with_no_default_assert_or_capture_rows() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
let form = match &app.overlay {
Some(Overlay::NewRequest(form)) => form,
_ => panic!("expected the New Request wizard to open"),
};
assert!(form.asserts.is_empty(), "no default blank assert row");
assert!(form.captures.is_empty(), "no default blank capture row");
}
#[test]
fn deleting_the_last_assert_or_capture_row_leaves_the_section_empty() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); assert_eq!(new_focus(&app), NewField::AddAssert);
press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::Assert(0));
app.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddAssert,
"back to empty, not re-seeded"
);
assert!(form_ref(&app).asserts.is_empty());
press(&mut app, KeyCode::PageDown); assert_eq!(new_focus(&app), NewField::AddCapture);
press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::Capture(0, CapCol::Name));
app.on_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddCapture,
"back to empty, not re-seeded"
);
assert!(form_ref(&app).captures.is_empty());
}
#[test]
fn creating_a_request_with_a_cookie_via_the_table() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::Cookie(0, HdrCol::Key));
for ch in "session".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); for ch in "abc123".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(
e[0].cookies,
vec![("session".to_string(), "abc123".to_string())]
);
}
#[test]
fn creating_a_request_with_a_text_form_field_via_the_table() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Key));
for ch in "username".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "bob".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e.len(), 1);
assert_eq!(e[0].form_fields.len(), 1);
assert_eq!(e[0].form_fields[0].key, "username");
assert_eq!(e[0].form_fields[0].value, "bob");
assert_eq!(e[0].form_fields[0].kind, crate::hurl::FormFieldKind::Text);
assert_eq!(e[0].form_fields[0].content_type, None);
}
#[test]
fn form_field_kind_dropdown_flips_text_and_file_and_persists_content_type() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "avatar".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Kind));
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => {
assert_eq!(
f.form_fields[0].kind,
crate::hurl::FormFieldKind::Text,
"a fresh row defaults to Text"
)
}
_ => panic!("form not open"),
}
assert!(
!form_ref(&app).kind_dropdown_open(),
"a populated (defaulted) Kind cell keeps the dropdown hidden"
);
press(&mut app, KeyCode::Enter); assert!(form_ref(&app).kind_dropdown_open());
press(&mut app, KeyCode::Down); match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => {
assert_eq!(f.form_fields[0].kind, crate::hurl::FormFieldKind::File)
}
_ => panic!("form not open"),
}
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Kind));
press(&mut app, KeyCode::Right);
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Value));
for ch in "avatar.png".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Right);
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Ctype));
for ch in "image/png".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(e[0].form_fields[0].kind, crate::hurl::FormFieldKind::File);
assert_eq!(
e[0].form_fields[0].content_type.as_deref(),
Some("image/png")
);
}
#[test]
fn esc_dismisses_the_kind_dropdown_before_cancelling_the_form() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app);
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Kind));
assert!(
!form_ref(&app).kind_dropdown_open(),
"a populated (defaulted) Kind cell keeps the dropdown hidden"
);
press(&mut app, KeyCode::Enter); assert!(form_ref(&app).kind_dropdown_open());
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_some(), "the form stays open");
assert!(
!form_ref(&app).kind_dropdown_open(),
"the dropdown is dismissed"
);
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_none(), "the form is cancelled");
}
#[test]
fn refocusing_the_kind_cell_keeps_the_dropdown_hidden() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app);
press(&mut app, KeyCode::Enter); assert!(form_ref(&app).kind_dropdown_open());
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Value));
press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Kind));
assert!(
!form_ref(&app).kind_dropdown_open(),
"leaving and returning does not reopen the dropdown (the cell always has Text/File set)"
);
}
#[test]
fn kind_cell_defaults_to_text_and_enter_reveals_the_dropdown_to_change_it() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); assert_eq!(
form_ref(&app).form_fields[0].kind,
crate::hurl::FormFieldKind::Text
);
assert!(
!form_ref(&app).kind_dropdown_open(),
"a defaulted Kind cell does not auto-open its dropdown"
);
press(&mut app, KeyCode::Enter); assert_eq!(
new_focus(&app),
NewField::FormField(0, FormCol::Kind),
"Enter stays put to reveal the dropdown"
);
assert!(
form_ref(&app).kind_dropdown_open(),
"Enter reveals the dropdown on a populated Kind cell"
);
assert_eq!(
form_ref(&app).form_fields[0].kind,
crate::hurl::FormFieldKind::Text,
"unchanged by Enter"
);
press(&mut app, KeyCode::Down); assert_eq!(
form_ref(&app).form_fields[0].kind,
crate::hurl::FormFieldKind::File
);
}
#[test]
fn pressing_enter_to_confirm_the_kind_dropdown_stays_on_the_kind_cell() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); assert_eq!(
form_ref(&app).form_fields[0].kind,
crate::hurl::FormFieldKind::File
);
press(&mut app, KeyCode::Enter); assert_eq!(
new_focus(&app),
NewField::FormField(0, FormCol::Kind),
"Enter confirms in place, no focus jump"
);
assert!(
!form_ref(&app).kind_dropdown_open(),
"Enter closes the dropdown"
);
assert_eq!(
form_ref(&app).form_fields[0].kind,
crate::hurl::FormFieldKind::File,
"the picked Type sticks"
);
press(&mut app, KeyCode::Tab);
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Value));
}
fn open_form_on_file_value(app: &mut TuiApp) {
open_form_on_form_field_kind(app); press(app, KeyCode::Enter); press(app, KeyCode::Down); press(app, KeyCode::Right); assert_eq!(new_focus(app), NewField::FormField(0, FormCol::Value));
}
fn open_form_on_file_ctype(app: &mut TuiApp) {
open_form_on_file_value(app); press(app, KeyCode::Right); assert_eq!(new_focus(app), NewField::FormField(0, FormCol::Ctype));
}
#[test]
fn content_type_and_description_are_independent_form_columns() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); for ch in "http://h/x".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); for ch in "avatar".chars() {
press(&mut app, KeyCode::Char(ch));
}
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Enter); press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Ctype));
press(&mut app, KeyCode::Esc); for ch in "text/csv".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Ctype));
press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Desc));
for ch in "a note".chars() {
press(&mut app, KeyCode::Char(ch));
}
let row = &form_ref(&app).form_fields[0];
assert_eq!(row.ctype.text(), "text/csv");
assert_eq!(row.desc.text(), "a note");
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
let e = &app.collections[0].entries;
assert_eq!(
e[0].form_fields[0].content_type.as_deref(),
Some("text/csv")
);
}
#[test]
fn infer_content_type_matches_known_extensions_and_falls_back_to_none() {
assert_eq!(infer_content_type("photo.gif"), Some("image/gif"));
assert_eq!(infer_content_type("photo.JPG"), Some("image/jpeg"));
assert_eq!(infer_content_type("photo.jpeg"), Some("image/jpeg"));
assert_eq!(infer_content_type("icon.svg"), Some("image/svg+xml"));
assert_eq!(infer_content_type("notes.txt"), Some("text/plain"));
assert_eq!(infer_content_type("page.htm"), Some("text/html"));
assert_eq!(infer_content_type("page.html"), Some("text/html"));
assert_eq!(infer_content_type("doc.pdf"), Some("application/pdf"));
assert_eq!(infer_content_type("data.xml"), Some("application/xml"));
assert_eq!(infer_content_type("clip.webm"), Some("video/webm"));
assert_eq!(infer_content_type("clip.WEBM"), Some("video/webm"));
assert_eq!(infer_content_type("clip.mp4"), Some("video/mp4"));
assert_eq!(infer_content_type("clip.m4v"), Some("video/mp4"));
assert_eq!(infer_content_type("clip.mov"), Some("video/quicktime"));
assert_eq!(infer_content_type("clip.avi"), Some("video/x-msvideo"));
assert_eq!(infer_content_type("clip.mkv"), Some("video/x-matroska"));
assert_eq!(infer_content_type("archive.zip"), None);
assert_eq!(infer_content_type("no_extension"), None);
}
#[test]
fn content_type_options_are_deduplicated_mime_types() {
let opts = content_type_options();
let unique: std::collections::HashSet<&str> = opts.iter().copied().collect();
assert_eq!(
opts.len(),
unique.len(),
"content_type_options() must not contain duplicate mime types"
);
let mut all_mimes: std::collections::HashSet<&str> =
CONTENT_TYPE_TABLE.iter().map(|(_, m)| *m).collect();
all_mimes.extend(super::new_request::COMMON_CONTENT_TYPES);
assert_eq!(
unique, all_mimes,
"every mime type in CONTENT_TYPE_TABLE and COMMON_CONTENT_TYPES must be offered exactly once"
);
assert!(opts.contains(&"image/jpeg"));
assert_eq!(opts.iter().filter(|o| **o == "image/jpeg").count(), 1);
assert!(opts.contains(&"text/html"));
assert_eq!(opts.iter().filter(|o| **o == "text/html").count(), 1);
assert!(
opts.contains(&"video/webm"),
"video/webm must be offered in the content-type dropdown"
);
assert_eq!(
opts.iter().filter(|o| **o == "video/mp4").count(),
1,
"mp4/m4v collapse to one entry"
);
for common in super::new_request::COMMON_CONTENT_TYPES {
assert!(
opts.contains(common),
"{common} should be offered in the content-type dropdown"
);
}
let mut sorted = opts.clone();
sorted.sort_unstable();
assert_eq!(
opts, sorted,
"content_type_options() must be in alphabetical order"
);
}
#[test]
fn content_type_dropdown_only_opens_for_file_kind_ctype_cells() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Ctype));
assert!(
!form_ref(&app).ctype_dropdown_open(),
"Text rows don't use the content-type dropdown"
);
}
#[test]
fn content_type_dropdown_lets_the_user_pick_a_mime_type_via_arrows() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app);
assert!(
form_ref(&app).ctype_dropdown_open(),
"the dropdown auto-opens over a File row's Desc cell"
);
press(&mut app, KeyCode::Down);
assert_eq!(
form_ref(&app).ctype_hi,
Some(1),
"one Down press should skip past the already-selected Auto"
);
press(&mut app, KeyCode::Enter);
let first_mime = content_type_options()[0];
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => assert_eq!(f.form_fields[0].ctype.text(), first_mime),
_ => panic!("form not open"),
}
assert!(!form_ref(&app).ctype_dropdown_open());
}
#[test]
fn content_type_dropdown_auto_option_respects_the_filter() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app);
let first_mime = content_type_options()[0];
for ch in first_mime.chars() {
press(&mut app, KeyCode::Char(ch)); }
assert_eq!(form_ref(&app).form_fields[0].ctype.text(), first_mime);
assert!(
!form_ref(&app).ctype_filtered_options().is_empty()
&& form_ref(&app).form_fields[0].ctype.text() == first_mime,
"sanity: the typed mime is still in the (now Auto-less) list"
);
press(&mut app, KeyCode::Up);
assert_eq!(
form_ref(&app).ctype_hi,
None,
"no Auto entry to move up into"
);
for _ in first_mime.chars() {
press(&mut app, KeyCode::Backspace);
}
assert_eq!(form_ref(&app).form_fields[0].ctype.text(), "");
press(&mut app, KeyCode::Tab); assert_eq!(
form_ref(&app).form_fields[0].ctype.text(),
"",
"Auto (implicitly selected) clears the override"
);
}
#[test]
fn typing_auto_keeps_the_auto_option_visible_and_typing_something_else_hides_it() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app);
let s = crate::i18n::Strings::for_language(&crate::i18n::Language::English);
for ch in "Auto".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert!(
form_ref(&app).ctype_auto_visible(&s),
"typing \"Auto\" itself must still match the Auto entry's own label"
);
for _ in "Auto".chars() {
press(&mut app, KeyCode::Backspace);
}
for ch in "image/png".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert!(
!form_ref(&app).ctype_auto_visible(&s),
"typing an unrelated mime must hide the Auto entry"
);
}
#[test]
fn esc_dismisses_the_content_type_dropdown_before_cancelling_the_form() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app);
assert!(form_ref(&app).ctype_dropdown_open());
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_some(), "the form stays open");
assert!(!form_ref(&app).ctype_dropdown_open());
press(&mut app, KeyCode::Esc); assert!(app.overlay.is_none(), "the form is cancelled");
}
#[test]
fn a_populated_content_type_cell_hides_its_dropdown_until_enter_reveals_it() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app); assert!(form_ref(&app).ctype_dropdown_open());
press(&mut app, KeyCode::Esc); for ch in "image/png".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert_eq!(form_ref(&app).form_fields[0].ctype.text(), "image/png");
press(&mut app, KeyCode::Left); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Right); press(&mut app, KeyCode::Left); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Ctype));
assert!(
!form_ref(&app).ctype_dropdown_open(),
"a populated Ctype cell keeps the dropdown hidden"
);
press(&mut app, KeyCode::Enter);
assert_eq!(
new_focus(&app),
NewField::FormField(0, FormCol::Ctype),
"Enter stays put to reveal the dropdown"
);
assert!(
form_ref(&app).ctype_dropdown_open(),
"Enter reveals the dropdown on a populated Ctype cell"
);
assert_eq!(
form_ref(&app).form_fields[0].ctype.text(),
"image/png",
"unchanged by Enter"
);
}
#[test]
fn typing_in_the_content_type_cell_filters_the_dropdown_like_the_key_dropdown_does() {
let mut app = TuiApp::default();
open_form_on_file_ctype(&mut app); let full_len = form_ref(&app).ctype_filtered_options().len();
assert_eq!(
full_len,
content_type_options().len(),
"an empty query shows every option, same as filter_headers"
);
for ch in "png".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert!(
form_ref(&app).ctype_dropdown_open(),
"typing keeps (re-opens) the dropdown, like the Key dropdown"
);
let filtered = form_ref(&app).ctype_filtered_options();
assert!(
!filtered.is_empty(),
"\"png\" should match at least image/png"
);
assert!(
filtered.len() < full_len,
"the list should have narrowed down from typing \"png\""
);
assert!(
filtered
.iter()
.all(|o| o.to_ascii_lowercase().contains("png")),
"every filtered option must match the typed text:\n{filtered:?}"
);
assert!(filtered.contains(&"image/png"));
for ch in "zzz".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert!(
form_ref(&app).ctype_filtered_options().is_empty(),
"no mime type contains \"pngzzz\""
);
}
#[test]
fn ctrl_f_on_a_file_value_cell_parks_the_wizard_and_opens_the_browser() {
let mut app = TuiApp::default();
open_form_on_file_value(&mut app);
app.on_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(
matches!(
app.overlay,
Some(Overlay::Browser(FileAction::PickFormFile(0), _))
),
"Ctrl+F should open the file browser targeting row 0"
);
assert!(
app.parked_wizard.is_some(),
"the wizard must be parked while browsing"
);
}
#[test]
fn enter_on_a_file_value_cell_also_opens_the_browser() {
let mut app = TuiApp::default();
open_form_on_file_value(&mut app);
press(&mut app, KeyCode::Enter);
assert!(
matches!(
app.overlay,
Some(Overlay::Browser(FileAction::PickFormFile(0), _))
),
"Enter should open the file browser targeting row 0, same as Ctrl+F"
);
assert!(
app.parked_wizard.is_some(),
"the wizard must be parked while browsing"
);
}
#[test]
fn enter_is_ignored_on_a_text_kind_value_cell() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Value));
press(&mut app, KeyCode::Enter);
assert!(
matches!(app.overlay, Some(Overlay::NewRequest(_))),
"the wizard stays open, no browser"
);
assert!(app.parked_wizard.is_none());
}
#[test]
fn ctrl_f_is_ignored_on_a_text_kind_value_cell() {
let mut app = TuiApp::default();
open_form_on_form_field_kind(&mut app); press(&mut app, KeyCode::Right); assert_eq!(new_focus(&app), NewField::FormField(0, FormCol::Value));
app.on_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(
matches!(app.overlay, Some(Overlay::NewRequest(_))),
"the wizard stays open, no browser"
);
assert!(app.parked_wizard.is_none());
}
#[test]
fn picking_a_file_restores_the_wizard_with_the_path_and_inferred_content_type() {
let dir = temp_dir("form_file_pick");
std::fs::write(dir.join("avatar.png"), b"fake-png").unwrap();
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
open_form_on_file_value(&mut app);
app.on_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(matches!(app.overlay, Some(Overlay::Browser(..))));
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Enter);
assert!(
app.parked_wizard.is_none(),
"the parked wizard is consumed on pick"
);
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => {
assert_eq!(
f.form_fields[0].value.text(),
dir.join("avatar.png").to_string_lossy()
);
assert_eq!(
f.form_fields[0].ctype.text(),
"image/png",
"content-type auto-inferred from .png"
);
assert_eq!(f.focus, NewField::FormField(0, FormCol::Value));
}
_ => panic!("expected the wizard to be restored"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cancelling_the_file_picker_restores_the_wizard_unchanged() {
let dir = temp_dir("form_file_cancel");
std::fs::write(dir.join("avatar.png"), b"fake-png").unwrap();
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
open_form_on_file_value(&mut app);
for ch in "old-value.png".chars() {
press(&mut app, KeyCode::Char(ch));
}
app.on_key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(matches!(app.overlay, Some(Overlay::Browser(..))));
press(&mut app, KeyCode::Esc);
assert!(app.parked_wizard.is_none());
match app.overlay.as_ref().unwrap() {
Overlay::NewRequest(f) => assert_eq!(f.form_fields[0].value.text(), "old-value.png"),
_ => panic!("expected the wizard to be restored"),
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn sending_a_request_with_both_body_and_form_fields_shows_a_clear_status_bar_error() {
let mut entry = HurlEntry {
method: "POST".to_string(),
url: "http://127.0.0.1:1/x".to_string(),
body: Some("{\"a\":1}".to_string()),
..Default::default()
};
entry.form_fields.push(crate::hurl::FormField {
key: "foo".to_string(),
value: "bar".to_string(),
kind: crate::hurl::FormFieldKind::Text,
content_type: None,
});
let mut app = TuiApp::default();
app.collections
.push(Collection::new("t".to_string(), vec![entry]));
app.active_tab = 1;
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
let r = app.response.lock().unwrap();
assert!(
!r.loading,
"must not be left stuck loading on a request that can never be built"
);
assert!(
r.error.contains("Body") && r.error.contains("Form"),
"the error should explain the conflict clearly: {}",
r.error
);
}
#[test]
fn wizard_tab_bar_renders_every_section_label() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
term.draw(|f| super::new_request::draw_new_request(f, &form, &s, &th, true))
.unwrap();
let out = buffer_text(term.backend().buffer());
for label in [
"All", "Headers", "Cookies", "Form", "Body", "Asserts", "Captures",
] {
assert!(out.contains(label), "tab bar should show '{label}':\n{out}");
}
}
#[test]
fn page_down_and_page_up_cycle_the_wizard_view_tab() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
assert_eq!(form_ref(&app).view_tab, WizardTab::All);
press(&mut app, KeyCode::PageDown);
assert_eq!(form_ref(&app).view_tab, WizardTab::Headers);
press(&mut app, KeyCode::PageDown);
assert_eq!(form_ref(&app).view_tab, WizardTab::Cookies);
press(&mut app, KeyCode::PageUp);
assert_eq!(form_ref(&app).view_tab, WizardTab::Headers);
press(&mut app, KeyCode::PageUp);
assert_eq!(
form_ref(&app).view_tab,
WizardTab::All,
"PageUp wraps back around to All"
);
}
#[test]
fn a_single_section_tab_shows_far_more_rows_than_the_combined_all_view() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
form.headers.clear();
for i in 0..8 {
let mut row = HeaderRow::new();
row.key = super::editor::Editor::new(&format!("Header{i}"), false);
row.value = super::editor::Editor::new(&format!("Value{i}"), false);
form.headers.push(row);
}
let count_visible = |form: &NewReq| {
let mut term = Terminal::new(TestBackend::new(100, 26)).unwrap();
term.draw(|f| super::new_request::draw_new_request(f, form, &s, &th, true))
.unwrap();
let out = buffer_text(term.backend().buffer());
(0..8)
.filter(|i| out.contains(&format!("Header{i}")))
.count()
};
let all_view_count = count_visible(&form);
form.view_tab = WizardTab::Headers;
let headers_tab_count = count_visible(&form);
assert!(
headers_tab_count > all_view_count,
"the Headers-only tab ({headers_tab_count} visible) should show more rows than the combined All view ({all_view_count} visible)"
);
}
#[test]
fn editing_on_a_section_tab_is_reflected_when_switching_back_to_all() {
let mut app = TuiApp::default();
open_form_on_header(&mut app);
press(&mut app, KeyCode::PageDown); assert_eq!(form_ref(&app).view_tab, WizardTab::Headers);
for ch in "X-Test".chars() {
press(&mut app, KeyCode::Char(ch));
}
assert_eq!(form_ref(&app).headers[0].key.text(), "X-Test");
press(&mut app, KeyCode::PageUp); assert_eq!(form_ref(&app).view_tab, WizardTab::All);
assert_eq!(form_ref(&app).headers[0].key.text(), "X-Test");
}
#[test]
fn section_tab_confines_tab_navigation_to_that_section() {
let mut app = TuiApp::default();
open_form_on_header(&mut app); press(&mut app, KeyCode::Char('X')); press(&mut app, KeyCode::PageDown); assert_eq!(form_ref(&app).view_tab, WizardTab::Headers);
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"switching tabs jumps to the first field"
);
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Value));
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::Header(0, HdrCol::Desc));
press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
press(&mut app, KeyCode::Tab);
assert_eq!(
new_focus(&app),
NewField::Header(0, HdrCol::Key),
"Tab must not leak out of the active section tab"
);
press(&mut app, KeyCode::BackTab);
assert_eq!(new_focus(&app), NewField::AddHeader);
}
#[test]
fn section_tab_confines_enter_navigation_to_that_section() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); press(&mut app, KeyCode::PageDown); assert_eq!(form_ref(&app).view_tab, WizardTab::Asserts);
assert_eq!(
new_focus(&app),
NewField::AddAssert,
"asserts start empty, so the entry point is the Add row"
);
press(&mut app, KeyCode::Enter); assert_eq!(new_focus(&app), NewField::Assert(0));
press(&mut app, KeyCode::Enter);
assert_eq!(
new_focus(&app),
NewField::Assert(0),
"Enter on a blank assert must not leak out into Captures"
);
}
#[test]
fn ctrl_shift_arrows_reorder_wizard_section_tabs() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
assert_eq!(form_ref(&app).view_tab, WizardTab::All);
press(&mut app, KeyCode::PageDown); assert_eq!(form_ref(&app).view_tab, WizardTab::Headers);
app.on_key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(
form_ref(&app).tab_order[1],
WizardTab::Cookies,
"Headers should have swapped forward past Cookies"
);
assert_eq!(form_ref(&app).tab_order[2], WizardTab::Headers);
assert_eq!(
form_ref(&app).view_tab,
WizardTab::Headers,
"the moved tab stays active"
);
app.on_key(KeyEvent::new(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(form_ref(&app).tab_order[1], WizardTab::Headers);
assert_eq!(form_ref(&app).tab_order[2], WizardTab::Cookies);
press(&mut app, KeyCode::PageUp); assert_eq!(form_ref(&app).view_tab, WizardTab::All);
app.on_key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(
form_ref(&app).tab_order[0],
WizardTab::All,
"All cannot be moved"
);
app.on_key(KeyEvent::new(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(
form_ref(&app).tab_order[1],
WizardTab::Headers,
"nothing can move into All's slot"
);
}
#[test]
fn switching_to_a_section_tab_jumps_focus_to_its_first_field() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
assert_eq!(new_focus(&app), NewField::Name);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::AddHeader,
"headers start empty, so the entry point is the Add row"
);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::AddCookie,
"cookies start empty, so the entry point is the Add row"
);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::AddFormField,
"form fields start empty, so the entry point is the Add row"
);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::Body,
"Body's only field is the editor itself, so this also puts it into editing mode"
);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::AddAssert,
"asserts start empty, so the entry point is the Add row"
);
press(&mut app, KeyCode::PageDown); assert_eq!(
new_focus(&app),
NewField::AddCapture,
"captures start empty, so the entry point is the Add row"
);
}
#[test]
fn page_up_page_down_cycle_collection_tabs_in_the_main_view() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.collections.push(Collection::new("web".into(), vec![]));
assert_eq!(app.active_tab, 0);
press(&mut app, KeyCode::PageDown);
assert_eq!(app.active_tab, 1);
press(&mut app, KeyCode::PageDown);
assert_eq!(app.active_tab, 2);
press(&mut app, KeyCode::PageUp);
assert_eq!(app.active_tab, 1);
press(&mut app, KeyCode::PageUp);
assert_eq!(app.active_tab, 0);
}
#[test]
fn tab_cycles_panes_in_reading_order_list_main_env_response() {
let mut app = TuiApp {
focus: Pane::Tabs,
..Default::default()
};
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.focus, Pane::List),
"Collections/requests list follows the Tabs bar"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.focus, Pane::Main),
"Request JSON follows the list, matching top-left -> top-right reading order"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.focus, Pane::GlobalEnv),
"Environments follows Request JSON, matching top-right -> bottom-left"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.focus, Pane::Response),
"Response follows Environments, matching bottom-left -> bottom-right"
);
press(&mut app, KeyCode::Tab);
assert!(
matches!(app.focus, Pane::Tabs),
"cycling wraps back around to the Tabs bar"
);
app.on_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE));
assert!(matches!(app.focus, Pane::Response));
app.on_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE));
assert!(matches!(app.focus, Pane::GlobalEnv));
}
#[test]
fn ctrl_left_right_are_a_third_alias_for_prev_next_tab_from_any_pane() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.collections.push(Collection::new("web".into(), vec![]));
app.focus = Pane::List; assert_eq!(app.active_tab, 0);
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(app.active_tab, 1);
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::CONTROL));
assert_eq!(app.active_tab, 2);
app.on_key(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(app.active_tab, 1);
app.on_key(KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL));
assert_eq!(app.active_tab, 0);
}
#[test]
fn alt_1_through_6_jump_directly_to_a_wizard_section_by_number() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
let cases = [
('1', NewField::AddHeader),
('2', NewField::AddCookie),
('3', NewField::AddFormField),
('4', NewField::Body),
('5', NewField::AddAssert),
('6', NewField::AddCapture),
];
for (digit, expected) in cases {
app.on_key(KeyEvent::new(KeyCode::Char(digit), KeyModifiers::ALT));
assert_eq!(
new_focus(&app),
expected,
"Alt+{digit} should jump straight to its section"
);
}
press(&mut app, KeyCode::PageDown); app.on_key(KeyEvent::new(KeyCode::Char('4'), KeyModifiers::ALT));
assert_eq!(
new_focus(&app),
NewField::Body,
"Alt+4 still jumps to Body from a different section tab"
);
press(&mut app, KeyCode::Char('1'));
assert!(
form_ref(&app).body.text().contains('1'),
"a plain digit key must type into the focused field"
);
}
#[test]
fn ctrl_up_down_jumps_directly_between_sections() {
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('n'));
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); assert_eq!(new_focus(&app), NewField::AddHeader);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddCookie,
"cookies start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddFormField,
"form fields start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(new_focus(&app), NewField::Body);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddAssert,
"asserts start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddCapture,
"captures start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Down, KeyModifiers::CONTROL));
assert_eq!(new_focus(&app), NewField::Name, "wraps back to the top");
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddCapture,
"captures start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddAssert,
"asserts start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(new_focus(&app), NewField::Body);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddFormField,
"form fields start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddCookie,
"cookies start empty, so the entry point is the Add row"
);
app.on_key(KeyEvent::new(KeyCode::Up, KeyModifiers::CONTROL));
assert_eq!(
new_focus(&app),
NewField::AddHeader,
"headers start empty, so the entry point is the Add row"
);
}
#[test]
fn scroll_window_keeps_the_focused_row_in_view_and_clamps_at_the_edges() {
let scroll = std::cell::Cell::new(0usize);
assert_eq!(scroll_window(&scroll, Some(0), 10, 3), 0);
assert_eq!(
scroll_window(&scroll, Some(2), 10, 3),
0,
"row 2 already fits in the [0,3) window"
);
assert_eq!(
scroll_window(&scroll, Some(5), 10, 3),
3,
"scrolls forward just enough to show row 5"
);
assert_eq!(
scroll_window(&scroll, Some(9), 10, 3),
7,
"last row pins the window to the final page"
);
assert_eq!(
scroll_window(&scroll, Some(0), 10, 3),
0,
"moving focus back scrolls back up"
);
assert_eq!(
scroll_window(&scroll, None, 10, 3),
0,
"no focus keeps the last offset, clamped"
);
assert_eq!(
scroll_window(&scroll, Some(4), 10, 20),
0,
"no scrolling needed when everything fits"
);
}
#[test]
fn many_headers_scroll_and_keep_the_focused_row_rendered() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
form.headers.clear();
for i in 0..8 {
let mut row = HeaderRow::new();
row.key = super::editor::Editor::new(&format!("Header{i}"), false);
row.value = super::editor::Editor::new(&format!("Value{i}"), false);
form.headers.push(row);
}
form.focus = NewField::Header(7, HdrCol::Key);
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
let mut term = Terminal::new(TestBackend::new(40, 5)).unwrap();
term.draw(|f| super::new_request::draw_header_table(f, area, &form, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains("Header7"),
"the focused last row must be scrolled into view:\n{text}"
);
}
#[test]
fn many_headers_always_keep_the_add_header_row_visible() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
form.headers.clear();
for i in 0..8 {
let mut row = HeaderRow::new();
row.key = super::editor::Editor::new(&format!("Header{i}"), false);
row.value = super::editor::Editor::new(&format!("Value{i}"), false);
form.headers.push(row);
}
form.focus = NewField::Header(0, HdrCol::Key);
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 40,
height: 5,
};
let mut term = Terminal::new(TestBackend::new(40, 5)).unwrap();
term.draw(|f| super::new_request::draw_header_table(f, area, &form, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains("Add header"),
"the Add Header hint must stay pinned/visible even when scrolled away from it:\n{text}"
);
assert!(
text.contains("Header0"),
"the focused first row should still be visible:\n{text}"
);
}
#[test]
fn section_height_grows_with_row_count_up_to_a_five_row_cap() {
use super::new_request::section_height;
assert_eq!(section_height(1, 0), 2);
assert_eq!(section_height(1, 1), 3);
assert_eq!(section_height(1, 5), 7);
assert_eq!(
section_height(1, 6),
7,
"caps at 5 rows once it would need to scroll"
);
assert_eq!(section_height(1, 100), 7);
assert_eq!(section_height(0, 1), 2);
assert_eq!(section_height(0, 5), 6);
assert_eq!(section_height(0, 6), 6);
}
#[test]
fn a_section_with_exactly_five_rows_shows_them_all_without_a_scrollbar() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
form.headers.clear();
for i in 0..5 {
let mut row = HeaderRow::new();
row.key = super::editor::Editor::new(&format!("Header{i}"), false);
row.value = super::editor::Editor::new(&format!("Value{i}"), false);
form.headers.push(row);
}
form.focus = NewField::Header(0, HdrCol::Key);
let height = super::new_request::section_height(1, 5);
assert_eq!(height, 7);
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 40,
height,
};
let mut term = Terminal::new(TestBackend::new(40, height)).unwrap();
term.draw(|f| super::new_request::draw_header_table(f, area, &form, &s, &th))
.unwrap();
let buf = term.backend().buffer().clone();
let text = buffer_text(&buf);
for i in 0..5 {
assert!(
text.contains(&format!("Header{i}")),
"row {i} should be fully visible:\n{text}"
);
}
assert!(
text.contains("Add header"),
"the Add Header hint should be visible:\n{text}"
);
for y in 0..height {
let sym = buf[(0, y)].symbol();
assert_ne!(
sym, "\u{2588}",
"no scrollbar thumb expected when all rows fit, row {y}"
);
assert_ne!(
sym, "\u{2502}",
"no scrollbar track expected when all rows fit, row {y}"
);
}
}
#[test]
fn a_sixth_row_triggers_a_scrollbar_rendered_in_the_leftmost_column() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
form.headers.clear();
for i in 0..6 {
let mut row = HeaderRow::new();
row.key = super::editor::Editor::new(&format!("Header{i}"), false);
row.value = super::editor::Editor::new(&format!("Value{i}"), false);
form.headers.push(row);
}
form.focus = NewField::Header(0, HdrCol::Key);
let height = super::new_request::section_height(1, 6);
assert_eq!(height, 7);
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 40,
height,
};
let mut term = Terminal::new(TestBackend::new(40, height)).unwrap();
term.draw(|f| super::new_request::draw_header_table(f, area, &form, &s, &th))
.unwrap();
let buf = term.backend().buffer().clone();
let text = buffer_text(&buf);
assert!(
text.contains("Add header"),
"the Add Header hint must stay pinned even while scrolling:\n{text}"
);
let mut found_left = false;
for y in 0..height {
let sym = buf[(0, y)].symbol();
if sym == "\u{2588}" || sym == "\u{2502}" {
found_left = true;
}
}
assert!(
found_left,
"expected a scrollbar thumb/track in the leftmost column:\n{text}"
);
let mut found_right = false;
for y in 0..height {
let sym = buf[(area.width - 1, y)].symbol();
if sym == "\u{2588}" || sym == "\u{2502}" {
found_right = true;
}
}
assert!(
!found_right,
"scrollbar should no longer render in the rightmost column:\n{text}"
);
}
#[test]
fn an_auto_detected_content_type_cell_shows_the_auto_placeholder() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
let mut row = FormRow::new();
row.key = super::editor::Editor::new("avatar", false);
row.value = super::editor::Editor::new("avatar.png", false);
row.kind = crate::hurl::FormFieldKind::File;
form.form_fields.push(row);
form.focus = NewField::FormField(0, FormCol::Value);
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 100,
height: 8,
};
let mut term = Terminal::new(TestBackend::new(100, 8)).unwrap();
term.draw(|f| super::new_request::draw_form_table(f, area, &form, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(s.content_type_auto_placeholder),
"an unfocused, unset Content-Type cell on a File row should show \"{}\":\n{text}",
s.content_type_auto_placeholder
);
}
#[test]
fn a_file_kind_value_cell_shows_a_folder_icon_hint_but_a_text_kind_one_does_not() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
const FOLDER_ICON: &str = "\u{1F4C1}";
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
let mut file_row = FormRow::new();
file_row.key = super::editor::Editor::new("avatar", false);
file_row.value = super::editor::Editor::new("avatar.png", false);
file_row.kind = crate::hurl::FormFieldKind::File;
let mut text_row = FormRow::new();
text_row.key = super::editor::Editor::new("name", false);
text_row.value = super::editor::Editor::new("crab", false);
text_row.kind = crate::hurl::FormFieldKind::Text;
form.form_fields.push(file_row);
form.form_fields.push(text_row);
form.focus = NewField::AddFormField;
let area = ratatui::layout::Rect {
x: 0,
y: 0,
width: 100,
height: 8,
};
let mut term = Terminal::new(TestBackend::new(100, 8)).unwrap();
term.draw(|f| super::new_request::draw_form_table(f, area, &form, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert_eq!(
text.matches(FOLDER_ICON).count(),
1,
"exactly the File-kind row's Value cell shows the folder icon hint:\n{text}"
);
form.focus = NewField::FormField(0, FormCol::Value);
term.draw(|f| super::new_request::draw_form_table(f, area, &form, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert_eq!(
text.matches(FOLDER_ICON).count(),
1,
"the folder icon hint still shows while the File row's Value cell is focused:\n{text}"
);
form.focus = NewField::AddFormField;
term.draw(|f| super::new_request::draw_form_table(f, area, &form, &s, &th))
.unwrap();
let buf = term.backend().buffer();
let row_y = 1u16; let icon_x = (0..area.width).find(|&x| buf[(x, row_y)].symbol() == FOLDER_ICON);
let value_text_x = (0..area.width.saturating_sub(2)).find(|&x| {
buf[(x, row_y)].symbol() == "p"
&& buf[(x + 1, row_y)].symbol() == "n"
&& buf[(x + 2, row_y)].symbol() == "g"
});
if let (Some(icon_x), Some(value_text_x)) = (icon_x, value_text_x) {
assert!(
icon_x < value_text_x,
"folder icon (col {icon_x}) must be left of the value text (col {value_text_x})"
);
} else {
panic!(
"expected to find both the folder icon and the start of \"avatar.png\" on row {row_y}"
);
}
}
#[test]
fn a_long_body_shows_a_scrollbar_in_the_leftmost_column() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut form = NewReq::new(String::new(), vec!["Scratch".to_string()], 0, None);
let long_body: String = (0..40)
.map(|i| format!("line{i}"))
.collect::<Vec<_>>()
.join("\n");
form.body = super::editor::Editor::new(&long_body, true);
form.view_tab = WizardTab::Body;
form.focus = NewField::Body;
let mut term = Terminal::new(TestBackend::new(100, 16)).unwrap();
term.draw(|f| super::new_request::draw_new_request(f, &form, &s, &th, true))
.unwrap();
let buf = term.backend().buffer().clone();
let text = buffer_text(&buf);
assert!(
text.contains("line39"),
"the body's last line (cursor position) is shown:\n{text}"
);
assert!(
!text.contains("line0"),
"the body overflows the visible area:\n{text}"
);
assert!(
text.contains('\u{2588}') || text.contains('\u{2502}'),
"expected a scrollbar thumb/track near the left edge of the Body editor:\n{text}"
);
}
#[test]
fn raw_mode_rejects_invalid_hurl_and_keeps_the_text_editable() {
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some(), "raw mode editor should be open");
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("this is not valid hurl {{{", true);
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(
app.overlay.is_some(),
"invalid hurl reopens the editor for correction"
);
if let Some(Overlay::Prompt { kind, editor, .. }) = &app.overlay {
assert!(matches!(kind, PromptKind::Raw(0)));
assert_eq!(
editor.text(),
"this is not valid hurl {{{",
"the invalid text is preserved"
);
}
assert_eq!(
app.collections[0].entries[0].url, "http://h/x",
"the entry is untouched"
);
assert!(!app.collections[0].entries[0].modified);
}
#[test]
fn raw_mode_edits_fields_the_wizard_does_not_expose() {
let mut entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
entry.expected_status = Some(200);
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some());
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
let new_text = editor.text().replace("HTTP 200", "HTTP 201");
*editor = super::editor::Editor::new(&new_text, true);
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.overlay.is_none(), "valid hurl commits and closes");
let e = &app.collections[0].entries[0];
assert_eq!(
e.expected_status,
Some(201),
"raw mode can edit fields the wizard hides"
);
assert!(e.modified);
}
#[test]
fn raw_mode_shift_arrow_selects_text_and_ctrl_y_copies_it() {
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("GET http://example.com/target\n[Asserts]", true);
editor.row = 0;
editor.col = 4; }
for _ in 0..3 {
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
}
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert_eq!(
editor.selected_text().as_deref(),
Some("htt"),
"Shift+Right extends a selection char by char"
);
}
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert_eq!(
editor.text(),
"GET http://example.com/target\n[Asserts]",
"Ctrl+Y only copies, it never edits"
);
assert_eq!(
editor.selected_text().as_deref(),
Some("htt"),
"the selection survives being copied"
);
}
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert!(
editor.selected_text().is_none(),
"a plain arrow move clears the selection"
);
}
}
#[test]
fn raw_mode_mouse_drag_selects_text_and_copies_on_release() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT));
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("GET http://example.com/target\n[Asserts]", true);
}
let mut term = Terminal::new(TestBackend::new(80, 24)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.prompt_editor_area;
assert!(
area.width > 0 && area.height > 0,
"the raw editor's text area must be recorded for hit-testing"
);
let ev = |kind, col_offset: u16| MouseEvent {
kind,
column: area.x + col_offset,
row: area.y,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev(MouseEventKind::Down(MouseButton::Left), 4));
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), 8));
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert_eq!(
editor.selected_text().as_deref(),
Some("http"),
"the drag selects exactly the dragged-over text"
);
}
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), 8));
assert!(
app.overlay.is_some(),
"the overlay stays open across the drag"
);
assert!(
matches!(app.status, Some(crate::i18n::Status::Copied)),
"releasing the drag inside the raw editor also sets the copied status message"
);
}
#[test]
fn raw_json_mode_rejects_invalid_json_and_keeps_the_text_editable() {
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some(), "raw JSON mode editor should be open");
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("this is not valid json {{{", true);
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(
app.overlay.is_some(),
"invalid JSON reopens the editor for correction"
);
if let Some(Overlay::Prompt { kind, editor, .. }) = &app.overlay {
assert!(matches!(kind, PromptKind::RawJson(0)));
assert_eq!(
editor.text(),
"this is not valid json {{{",
"the invalid text is preserved"
);
}
assert_eq!(
app.collections[0].entries[0].url, "http://h/x",
"the entry is untouched"
);
assert!(!app.collections[0].entries[0].modified);
}
#[test]
fn raw_json_mode_edits_fields_the_wizard_does_not_expose() {
let mut entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
entry.expected_status = Some(200);
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT));
assert!(app.overlay.is_some());
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
let new_text = editor.text().replace("\"GET\"", "\"POST\"");
*editor = super::editor::Editor::new(&new_text, true);
}
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.overlay.is_none(), "valid JSON commits and closes");
let e = &app.collections[0].entries[0];
assert_eq!(e.method, "POST", "raw JSON mode edits the method");
assert_eq!(
e.expected_status,
Some(200),
"fields the JSON view doesn't expose are preserved"
);
assert!(e.modified);
}
#[test]
fn raw_json_mode_shift_arrow_selects_text_and_ctrl_y_copies_it() {
let entry = HurlEntry::from_fields("r", "GET", "http://h/x", vec![], "");
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT));
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("{\n \"method\": \"GET\"\n}", true);
editor.row = 1;
editor.col = 2; }
for _ in 0..3 {
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT));
}
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert_eq!(
editor.selected_text().as_deref(),
Some("\"me"),
"Shift+Right extends a selection char by char"
);
}
app.on_key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL));
if let Some(Overlay::Prompt { editor, .. }) = &app.overlay {
assert_eq!(
editor.text(),
"{\n \"method\": \"GET\"\n}",
"Ctrl+Y only copies, it never edits"
);
assert_eq!(
editor.selected_text().as_deref(),
Some("\"me"),
"the selection survives being copied"
);
}
}
#[test]
fn editor_multiline_selection_uses_stream_semantics() {
use super::editor::Editor;
let mut ed = Editor::new("first line\nsecond\nthird line", true);
ed.row = 0;
ed.col = 6; ed.begin_selection_if_needed();
ed.row = 2;
ed.col = 5; assert_eq!(ed.selected_text().as_deref(), Some("line\nsecond\nthird"));
let mut ed2 = Editor::new("first line\nsecond\nthird line", true);
ed2.row = 2;
ed2.col = 5;
ed2.begin_selection_if_needed();
ed2.row = 0;
ed2.col = 6;
assert_eq!(ed2.selected_text().as_deref(), Some("line\nsecond\nthird"));
let mut ed3 = Editor::new("abc", false);
ed3.begin_selection_if_needed();
assert!(ed3.selected_text().is_none());
}
#[test]
fn opening_the_git_wizard_offers_the_recent_urls_most_recent_first() {
let mut app = TuiApp {
recent_git_urls: vec![
"https://example.test/a.git".into(),
"https://example.test/b.git".into(),
],
..Default::default()
};
app.open_remote_wizard(RemoteKind::Collection);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => assert_eq!(w.recent, app.recent_git_urls),
_ => panic!("wizard did not open"),
}
}
#[test]
fn down_opens_the_recent_urls_dropdown_and_enter_picks_one() {
let mut app = TuiApp {
recent_git_urls: vec![
"https://example.test/a.git".into(),
"https://example.test/b.git".into(),
],
..Default::default()
};
app.open_remote_wizard(RemoteKind::Collection);
press(&mut app, KeyCode::Down);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(matches!(
w.stage,
RemoteStage::Connect {
field: 0,
recent_sel: Some(0)
}
));
}
_ => panic!("wizard closed"),
}
press(&mut app, KeyCode::Down);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(
matches!(
w.stage,
RemoteStage::Connect {
field: 0,
recent_sel: Some(1)
}
),
"Down moves to the next item"
);
}
_ => panic!("wizard closed"),
}
press(&mut app, KeyCode::Enter);
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert_eq!(w.url.text(), "https://example.test/b.git");
assert!(matches!(
w.stage,
RemoteStage::Loading {
phase: LoadPhase::Refs
}
));
}
_ => panic!("wizard closed"),
}
}
#[test]
fn up_from_the_first_recent_item_closes_the_dropdown() {
let mut app = TuiApp {
recent_git_urls: vec!["https://example.test/a.git".into()],
..Default::default()
};
app.open_remote_wizard(RemoteKind::Collection);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Up); match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(matches!(
w.stage,
RemoteStage::Connect {
field: 0,
recent_sel: None
}
));
}
_ => panic!("wizard closed"),
}
}
#[test]
fn typing_closes_the_dropdown_and_edits_the_field() {
let mut app = TuiApp {
recent_git_urls: vec!["https://example.test/a.git".into()],
..Default::default()
};
app.open_remote_wizard(RemoteKind::Collection);
press(&mut app, KeyCode::Down); press(&mut app, KeyCode::Char('x'));
match &app.overlay {
Some(Overlay::RemoteGit(w)) => {
assert!(
matches!(
w.stage,
RemoteStage::Connect {
field: 0,
recent_sel: None
}
),
"typing closes the dropdown"
);
assert_eq!(
w.url.text(),
"x",
"the keystroke is applied to the url field"
);
}
_ => panic!("wizard closed"),
}
}
#[test]
fn completing_a_git_load_remembers_the_url_most_recent_first() {
use super::editor::Editor;
let mut app = TuiApp {
recent_git_urls: vec!["https://example.test/old.git".into()],
..Default::default()
};
let mut w = RemoteWizard::new(RemoteKind::Collection, app.recent_git_urls.clone());
w.url = Editor::new("https://example.test/new.git", false);
w.selected_path = Some("api.hurl".into());
let keep_open = app.apply_git_msg(&mut w, GitMsg::Content(Ok("GET http://h/x\n".into())));
assert!(!keep_open, "a collection load closes the wizard");
assert_eq!(
app.recent_git_urls,
vec![
"https://example.test/new.git".to_string(),
"https://example.test/old.git".to_string()
],
"the just-used URL moves to the front"
);
}
#[test]
fn reusing_the_same_url_moves_it_to_the_front_without_duplicating() {
let mut app = TuiApp {
recent_git_urls: vec![
"https://example.test/a.git".into(),
"https://example.test/b.git".into(),
],
..Default::default()
};
app.remember_git_url("https://example.test/b.git");
assert_eq!(
app.recent_git_urls,
vec![
"https://example.test/b.git".to_string(),
"https://example.test/a.git".to_string()
],
"re-using a URL moves it to the front instead of duplicating it"
);
}
#[test]
fn ctrl_w_closes_the_active_tab_but_not_the_built_in_one() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.on_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(app.collections.len(), 1, "the 'api' tab was closed");
assert_eq!(app.active_tab, 0);
app.on_key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(app.collections.len(), 1, "the built-in tab can't be closed");
}
#[test]
fn u_reopens_the_most_recently_closed_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.close_active_tab();
assert_eq!(app.collections.len(), 1);
app.on_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(app.collections.len(), 2, "the closed tab came back");
assert_eq!(app.collections[1].name, "api");
assert_eq!(app.active_tab, 1, "the reopened tab becomes active");
}
#[test]
fn reopening_with_no_closed_tabs_is_a_no_op() {
let mut app = TuiApp::default();
app.on_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(app.collections.len(), 1);
}
#[test]
fn reopened_tabs_come_back_in_last_closed_first_order() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.collections.push(Collection::new("web".into(), vec![]));
app.active_tab = 1;
app.close_active_tab(); assert_eq!(app.collections[1].name, "web");
app.active_tab = 1;
app.close_active_tab(); assert_eq!(app.collections.len(), 1);
app.reopen_closed_tab();
assert_eq!(
app.collections[app.active_tab].name, "web",
"most recently closed comes back first"
);
app.reopen_closed_tab();
assert!(
app.collections.iter().any(|c| c.name == "api"),
"the earlier closed tab is restored too"
);
}
fn git_workspace_tab(tag: &str) -> (std::path::PathBuf, Collection) {
let dir = std::env::temp_dir().join(format!(
"paperboy_close_git_ws_{tag}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("alpha.hurl"), "GET https://example.com/alpha\n").unwrap();
let mut col = Collection::new("downloaded".into(), vec![]);
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
(dir, col)
}
#[test]
fn closing_a_git_downloaded_workspace_tab_asks_first_instead_of_closing_immediately() {
let (dir, col) = git_workspace_tab("ask_first");
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = 1;
app.close_active_tab();
assert_eq!(app.collections.len(), 2, "the tab is not closed yet");
match &app.overlay {
Some(Overlay::CloseGitWorkspace { idx, path, sel }) => {
assert_eq!(*idx, 1);
assert_eq!(path, &dir);
assert_eq!(*sel, 0, "Keep is the default, non-destructive choice");
}
other => {
let _ = other; panic!("expected a CloseGitWorkspace confirm popup");
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn keeping_a_git_workspace_closes_the_tab_but_leaves_the_folder_and_stays_undoable() {
let (dir, col) = git_workspace_tab("keep");
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = 1;
app.close_active_tab();
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.collections.len(), 1, "the tab was closed");
assert!(app.overlay.is_none());
assert!(dir.exists(), "the folder is kept on disk");
app.on_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(
app.collections.len(),
2,
"u reopens it since the folder is still there"
);
assert_eq!(
app.collections[app.active_tab].workspace_root,
Some(dir.clone())
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn deleting_a_git_workspace_closes_the_tab_removes_the_folder_and_is_skipped_by_u() {
let (dir, col) = git_workspace_tab("delete");
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = 1;
app.close_active_tab();
app.on_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
app.on_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.collections.len(), 1, "the tab was closed");
assert!(app.overlay.is_none());
assert!(!dir.exists(), "the downloaded folder was deleted");
app.on_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert_eq!(
app.collections.len(),
1,
"u is a no-op — the deleted tab was skipped"
);
}
#[test]
fn escape_cancels_closing_a_git_workspace_tab_leaving_it_open() {
let (dir, col) = git_workspace_tab("cancel");
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = 1;
app.close_active_tab();
app.on_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.collections.len(), 2, "the tab was not closed");
assert!(app.overlay.is_none());
assert!(dir.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn closing_a_locally_chosen_workspace_tab_is_immediate_and_unprompted() {
let dir = std::env::temp_dir().join(format!("paperboy_close_local_ws_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut col = Collection::new("local".into(), vec![]);
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = 1;
app.close_active_tab();
assert_eq!(
app.collections.len(),
1,
"closed immediately, no confirmation needed"
);
assert!(app.overlay.is_none());
assert!(dir.exists(), "a locally-chosen folder is never touched");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn ctrl_shift_arrows_reorder_the_active_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.collections.push(Collection::new("web".into(), vec![]));
app.active_tab = 1;
app.on_key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(app.collections[1].name, "web");
assert_eq!(app.collections[2].name, "api");
assert_eq!(
app.active_tab, 2,
"the moved tab stays active at its new position"
);
app.on_key(KeyEvent::new(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(app.collections[1].name, "api");
assert_eq!(app.collections[2].name, "web");
assert_eq!(app.active_tab, 1);
}
#[test]
fn tab_reordering_never_moves_the_built_in_tab() {
let mut app = TuiApp::default();
app.collections.push(Collection::new("api".into(), vec![]));
app.active_tab = 1;
app.on_key(KeyEvent::new(
KeyCode::Left,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(app.collections[0].name, "Request");
assert_eq!(app.collections[1].name, "api");
assert_eq!(app.active_tab, 1, "the move was rejected");
app.active_tab = 0;
app.on_key(KeyEvent::new(
KeyCode::Right,
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
assert_eq!(app.collections[0].name, "Request");
assert_eq!(app.active_tab, 0);
}
#[test]
fn editing_a_request_populates_asserts_and_captures_in_the_combined_view() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut entry = HurlEntry::from_fields("orig", "GET", "http://h/x", vec![], "");
entry.asserts = vec!["status == 200".to_string()];
entry.captures = vec![("token".to_string(), "jsonpath \"$.token\"".to_string())];
let mut app = TuiApp::default();
app.collections[0].entries.push(entry);
app.focus = Pane::List;
press(&mut app, KeyCode::Enter); let form = match &app.overlay {
Some(Overlay::NewRequest(form)) => form,
_ => panic!("expected the Edit Request wizard to open"),
};
assert_eq!(form.asserts.len(), 1);
assert_eq!(form.asserts[0].expr.text(), "status == 200");
assert_eq!(form.captures.len(), 1);
assert_eq!(form.captures[0].name.text(), "token");
assert_eq!(form.captures[0].expr.text(), "jsonpath \"$.token\"");
let mut term = Terminal::new(TestBackend::new(100, 40)).unwrap();
term.draw(|f| super::new_request::draw_new_request(f, form, &s, &th, true))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("status == 200"),
"the assert row should render:\n{out}"
);
assert!(
out.contains("token") && out.contains("jsonpath"),
"the capture row should render:\n{out}"
);
}
fn collection_with_folders() -> Collection {
Collection::new(
"api".into(),
vec![
HurlEntry::from_fields("root", "GET", "http://h/root", vec![], ""),
HurlEntry::from_fields("A/one", "GET", "http://h/one", vec![], ""),
HurlEntry::from_fields("A/two", "GET", "http://h/two", vec![], ""),
HurlEntry::from_fields("A/B/deep", "GET", "http://h/deep", vec![], ""),
],
)
}
fn row_of_up(col: &Collection) -> usize {
col.rows()
.iter()
.position(|r| matches!(r, crate::tree::Row::Up))
.expect("no Up row")
}
fn row_of_folder(col: &Collection, name: &str) -> usize {
col.rows()
.iter()
.position(|r| matches!(r, crate::tree::Row::Folder(n) if n == name))
.unwrap_or_else(|| panic!("no Folder({name}) row"))
}
fn row_of_entry(col: &Collection, title: &str) -> usize {
let idx = col
.entries
.iter()
.position(|e| e.title == title)
.expect("no such entry");
col.rows()
.iter()
.position(|r| matches!(r, crate::tree::Row::Entry(i) if *i == idx))
.unwrap_or_else(|| panic!("entry {title:?} is not visible in the current folder"))
}
#[test]
fn down_arrow_steps_through_folder_and_entry_rows_at_the_root() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.focus = Pane::List;
let folder_row = row_of_folder(&app.collections[0], "A");
let root_row = row_of_entry(&app.collections[0], "root");
app.collections[0].list_cursor = folder_row;
press(&mut app, KeyCode::Down);
assert_eq!(
app.collections[0].list_cursor, root_row,
"Down moves to the next row (the leaf request)"
);
let root_idx = app.collections[0]
.entries
.iter()
.position(|e| e.title == "root")
.unwrap();
assert_eq!(app.collections[0].selected_entry, root_idx);
}
#[test]
fn enter_descends_into_a_folder_and_backspace_ascends() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.focus = Pane::List;
app.collections[0].list_cursor = row_of_folder(&app.collections[0], "A");
press(&mut app, KeyCode::Enter);
assert_eq!(
app.collections[0].folder,
vec!["A".to_string()],
"Enter descends into the folder"
);
assert_eq!(
app.collections[0].list_cursor, 0,
"cursor resets on entering a folder"
);
assert!(
app.overlay.is_none(),
"descending into a folder must not open the wizard"
);
press(&mut app, KeyCode::Backspace);
assert!(
app.collections[0].folder.is_empty(),
"Backspace ascends back to the root"
);
}
#[test]
fn enter_on_the_up_row_ascends_to_the_parent_folder() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.collections[0].folder = vec!["A".to_string()];
app.focus = Pane::List;
app.collections[0].list_cursor = row_of_up(&app.collections[0]);
press(&mut app, KeyCode::Enter);
assert!(
app.collections[0].folder.is_empty(),
"Enter on the Up row goes back to the root"
);
}
#[test]
fn enter_on_a_request_row_inside_a_folder_still_opens_the_edit_wizard() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.collections[0].folder = vec!["A".to_string()];
app.focus = Pane::List;
app.collections[0].list_cursor = row_of_entry(&app.collections[0], "A/one");
app.collections[0].selected_entry = app.collections[0]
.entries
.iter()
.position(|e| e.title == "A/one")
.unwrap();
press(&mut app, KeyCode::Enter);
assert!(
app.overlay.is_some(),
"Enter on a request row opens the Edit Request wizard"
);
assert!(matches!(app.focus, Pane::Main));
}
#[test]
fn delete_is_a_no_op_on_a_folder_or_up_row() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.focus = Pane::List;
let before = app.collections[0].entries.len();
app.collections[0].list_cursor = row_of_folder(&app.collections[0], "A");
press(&mut app, KeyCode::Char('x'));
assert_eq!(
app.collections[0].entries.len(),
before,
"deleting a folder row is a no-op"
);
app.collections[0].folder = vec!["A".to_string()];
app.collections[0].list_cursor = row_of_up(&app.collections[0]);
press(&mut app, KeyCode::Char('x'));
assert_eq!(
app.collections[0].entries.len(),
before,
"deleting the Up row is a no-op"
);
}
#[test]
fn delete_removes_a_request_row_while_browsing_a_folder() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.collections[0].folder = vec!["A".to_string()];
app.focus = Pane::List;
app.collections[0].list_cursor = row_of_entry(&app.collections[0], "A/one");
app.collections[0].selected_entry = app.collections[0]
.entries
.iter()
.position(|e| e.title == "A/one")
.unwrap();
press(&mut app, KeyCode::Char('x'));
assert!(
!app.collections[0]
.entries
.iter()
.any(|e| e.title == "A/one"),
"the highlighted request is deleted"
);
assert!(
app.collections[0]
.entries
.iter()
.any(|e| e.title == "A/two"),
"other requests in the folder remain"
);
}
#[test]
fn requests_list_breadcrumb_shows_the_current_folder() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.collections[0].folder = vec!["A".to_string(), "B".to_string()];
app.focus = Pane::List;
let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap();
term.draw(|f| super::draw::draw_collection_left(f, f.area(), &app, 0, &s, &th))
.unwrap();
let out = buffer_text(term.backend().buffer());
assert!(
out.contains("A") && out.contains("B"),
"the breadcrumb shows the nested folder path:\n{out}"
);
}
#[test]
fn new_request_prefills_the_current_folder_in_the_name_field() {
let mut app = TuiApp::default();
app.collections[0] = collection_with_folders();
app.collections[0].folder = vec!["A".to_string(), "B".to_string()];
app.focus = Pane::List;
press(&mut app, KeyCode::Char('n'));
match &app.overlay {
Some(Overlay::NewRequest(form)) => {
assert_eq!(
form.name.text(),
"A/B/",
"the Name field is prefilled with the current folder"
);
}
_ => panic!("expected the New Request form to open"),
}
}
#[test]
fn persisted_state_resyncs_the_folder_view_after_reload() {
let mut col = collection_with_folders();
col.selected_entry = col
.entries
.iter()
.position(|e| e.title == "A/B/deep")
.unwrap();
col.folder = vec![];
col.list_cursor = 0;
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
let (restored, _pending) = persisted.into_collection(None);
assert_eq!(restored.folder, vec!["A".to_string(), "B".to_string()]);
}
#[test]
fn a_workspace_tabs_entries_are_not_snapshotted_and_are_re_read_fresh_on_restore() {
let dir = std::env::temp_dir().join(format!("paperboy_ws_persist_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("orders.hurl");
std::fs::write(&file, "GET https://example.com/one\n").unwrap();
let mut col = Collection::new("orders".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_filter_hurl_json = false;
col.path = Some(file.clone());
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
assert!(
persisted.entries.is_empty(),
"a workspace tab's entries are never snapshotted"
);
assert_eq!(
persisted.workspace_root.as_deref(),
Some(dir.to_string_lossy().as_ref())
);
assert_eq!(persisted.workspace_filter_hurl_json, Some(false));
let (restored, _pending) = persisted.into_collection(None);
assert_eq!(restored.workspace_root, Some(dir.clone()));
assert!(!restored.workspace_filter_hurl_json);
assert_eq!(restored.path, Some(file));
assert_eq!(
restored.entries.len(),
1,
"the file is re-parsed fresh from disk, not from any snapshot"
);
assert_eq!(restored.entries[0].url, "https://example.com/one");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_workspace_tab_whose_selected_file_has_vanished_restores_to_the_empty_no_collection_state() {
let dir = std::env::temp_dir().join(format!(
"paperboy_ws_persist_missing_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let missing_file = dir.join("gone.hurl");
let mut col = Collection::new("gone".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.path = Some(missing_file);
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
let (restored, _pending) = persisted.into_collection(None);
assert_eq!(restored.workspace_root, Some(dir.clone()));
assert_eq!(restored.path, None);
assert!(restored.entries.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_workspace_tab_whose_entire_root_folder_has_vanished_is_fully_reset_not_just_its_file() {
let dir = std::env::temp_dir().join(format!(
"paperboy_ws_persist_root_gone_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let mut col = Collection::new("ghost-repo".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
col.path = Some(dir.join("gone.hurl"));
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
let (restored, _pending) = persisted.into_collection(None);
assert_eq!(
restored.workspace_root, None,
"the dead root itself must be cleared, not just the file"
);
assert_eq!(restored.path, None);
assert!(restored.entries.is_empty());
assert!(
!restored.workspace_downloaded_from_git,
"no folder is left to offer keeping/deleting on close"
);
}
#[test]
fn restoring_a_tab_whose_workspace_root_vanished_shows_a_status_explaining_it() {
let dir = std::env::temp_dir().join(format!(
"paperboy_ws_apply_persisted_root_gone_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let mut col = Collection::new("ghost-repo".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
let tab = crate::persistence::PersistedTab::from_collection(&col, None);
let state = crate::persistence::PersistedState {
tabs: vec![tab],
..Default::default()
};
let mut app = TuiApp::default();
app.apply_persisted(state);
assert_eq!(app.collections[0].workspace_root, None);
match &app.status {
Some(crate::i18n::Status::WorkspaceFolderMissing(name)) => assert_eq!(name, "ghost-repo"),
other => panic!("expected a WorkspaceFolderMissing status, got {other:?}"),
}
}
#[test]
fn an_ordinary_non_workspace_tab_still_persists_a_full_entries_snapshot() {
let col = collection_with_folders();
let persisted = crate::persistence::PersistedTab::from_collection(&col, None);
assert_eq!(persisted.entries.len(), col.entries.len());
assert!(persisted.workspace_root.is_none());
assert!(persisted.workspace_filter_hurl_json.is_none());
let (restored, _pending) = persisted.into_collection(None);
assert_eq!(restored.entries.len(), col.entries.len());
assert!(restored.workspace_root.is_none());
assert!(
restored.workspace_filter_hurl_json,
"defaults to true when absent from an older state file"
);
}
fn workspace_temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("paperboy_ws_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("alpha.hurl"), "GET https://example.com/alpha\n").unwrap();
std::fs::write(dir.join("notes.txt"), "not a collection").unwrap();
std::fs::write(dir.join("sub").join("beta.json"), "[]").unwrap();
dir
}
#[test]
fn space_confirms_the_current_directory_as_a_workspace_root_and_opens_the_picker() {
let dir = workspace_temp_dir("space_confirm");
let mut app = TuiApp {
last_browse_dir: Some(dir.clone()),
..Default::default()
};
app.open_browser(FileAction::OpenWorkspace);
press(&mut app, KeyCode::Char(' '));
let ci = app.active_tab;
assert!(ci > 0, "a new tab was created for the workspace");
assert_eq!(app.collections[ci].workspace_root, Some(dir.clone()));
assert_eq!(app.collections[ci].path, None, "no file chosen yet");
match &app.overlay {
Some(Overlay::WorkspacePicker(picker)) => {
assert_eq!(picker.root, dir);
assert_eq!(picker.collection_idx, ci);
}
_ => panic!("expected the WorkspacePicker to open immediately after confirming the root"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_picker_nav_only_moves_across_file_rows_skipping_directories() {
let dir = workspace_temp_dir("nav");
let picker = WorkspacePickerState::new(0, dir.clone(), true);
assert!(
picker
.entries
.iter()
.any(|e| e.is_dir && e.display_name == "sub")
);
assert!(!picker.entries.iter().any(|e| e.display_name == "notes.txt"));
let mut picker = picker;
let first = picker.selected;
picker.nav(1);
assert_ne!(picker.selected, first, "moved to the next file row");
assert!(
!picker.entries[picker.selected].is_dir,
"selection always lands on a file row"
);
picker.nav(100); assert!(!picker.entries[picker.selected].is_dir);
picker.nav(-100); assert!(!picker.entries[picker.selected].is_dir);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn tab_toggles_the_filter_rescans_and_syncs_the_choice_back_onto_the_collection() {
let dir = workspace_temp_dir("filter_toggle");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_filter_hurl_json = true;
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.overlay = Some(Overlay::WorkspacePicker(WorkspacePickerState::new(
ci,
dir.clone(),
true,
)));
press(&mut app, KeyCode::Tab);
match &app.overlay {
Some(Overlay::WorkspacePicker(picker)) => {
assert!(!picker.filter_hurl_json, "Tab toggles the filter off");
assert!(
picker.entries.iter().any(|e| e.display_name == "notes.txt"),
"unfiltered rescan shows every file"
);
}
_ => panic!("expected the picker to stay open"),
}
assert!(
!app.collections[ci].workspace_filter_hurl_json,
"the toggle is written back onto the Collection"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_on_a_file_row_loads_it_and_closes_the_picker() {
let dir = workspace_temp_dir("enter_load");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
let mut picker = WorkspacePickerState::new(ci, dir.clone(), true);
assert!(!picker.entries[picker.selected].is_dir);
picker.selected = picker
.entries
.iter()
.position(|e| e.display_name == "alpha.hurl")
.unwrap();
app.overlay = Some(Overlay::WorkspacePicker(picker));
press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none(), "picker closes once a file is chosen");
assert_eq!(app.collections[ci].path, Some(dir.join("alpha.hurl")));
assert_eq!(app.collections[ci].entries.len(), 1);
assert_eq!(
app.collections[ci].entries[0].url,
"https://example.com/alpha"
);
assert_eq!(
app.collections[ci].workspace_root,
Some(dir.clone()),
"stays bound to the same folder"
);
assert_eq!(
app.collections[ci].name, "ws",
"the tab's own name is untouched by picking a file"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn new_request_form_for_tab(ci: usize, url: &str) -> NewReq {
let names: Vec<String> = (0..=ci).map(|i| format!("tab{i}")).collect();
let mut form = NewReq::new(String::new(), names, ci, None);
form.url = super::editor::Editor::new(url, false);
form
}
#[test]
fn new_request_targeting_a_workspace_opens_the_destination_picker() {
let dir = workspace_temp_dir("newreq_opens_picker");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.submit_new_request(new_request_form_for_tab(ci, "http://h/new"));
assert!(
app.collections[ci].entries.is_empty(),
"the request is NOT silently pushed onto the loaded file"
);
assert!(
app.pending_workspace_request.is_some(),
"the request is parked awaiting a destination"
);
match &app.overlay {
Some(Overlay::WorkspacePicker(p)) => {
assert_eq!(p.collection_idx, ci);
assert!(p.adding_request, "picker is in add-request mode");
}
_ => panic!("expected the workspace destination picker to open"),
}
assert_eq!(app.active_tab, ci, "focus moved to the workspace tab");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn picking_a_file_in_add_mode_appends_the_request_and_shows_it() {
let dir = workspace_temp_dir("newreq_append");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.submit_new_request(new_request_form_for_tab(ci, "http://h/new"));
if let Some(Overlay::WorkspacePicker(picker)) = &mut app.overlay {
picker.selected = picker
.entries
.iter()
.position(|e| e.display_name == "alpha.hurl")
.unwrap();
} else {
panic!("expected the destination picker");
}
press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none(), "the picker closes after landing");
assert!(
app.pending_workspace_request.is_none(),
"the parked request has been placed"
);
assert_eq!(app.collections[ci].path, Some(dir.join("alpha.hurl")));
assert_eq!(app.collections[ci].entries.len(), 2);
let added = app.collections[ci].entries.last().unwrap();
assert_eq!(added.url, "http://h/new");
assert!(
added.user_added && added.modified,
"marked as an unsaved add"
);
assert_eq!(
app.collections[ci].selected_entry, 1,
"the new request is selected so it's visible"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn n_in_the_picker_creates_a_new_collection_holding_the_request() {
let dir = workspace_temp_dir("newreq_new_collection");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.submit_new_request(new_request_form_for_tab(ci, "http://h/new"));
press(&mut app, KeyCode::Char('n'));
match &app.overlay {
Some(Overlay::Prompt { kind, .. }) => {
assert!(matches!(kind, PromptKind::NewWorkspaceCollection(idx) if *idx == ci));
}
_ => panic!("expected the new-collection name prompt"),
}
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("api/orders", false);
}
press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none());
assert!(app.pending_workspace_request.is_none());
assert_eq!(
app.collections[ci].path,
Some(dir.join("api").join("orders.hurl")),
"extension defaulted to .hurl and rooted under the workspace"
);
assert_eq!(app.collections[ci].entries.len(), 1);
let added = &app.collections[ci].entries[0];
assert_eq!(added.url, "http://h/new");
assert!(added.user_added && added.modified);
assert!(matches!(
app.status,
Some(crate::i18n::Status::WorkspaceCollectionCreated(_))
));
app.active_tab = ci;
app.do_file_action(
FileAction::SaveCollection,
dir.join("api").join("orders.hurl").to_str().unwrap(),
);
assert!(dir.join("api").join("orders.hurl").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn escaping_the_destination_picker_clears_the_parked_request() {
let dir = workspace_temp_dir("newreq_cancel");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.submit_new_request(new_request_form_for_tab(ci, "http://h/new"));
assert!(app.pending_workspace_request.is_some());
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none());
assert!(
app.pending_workspace_request.is_none(),
"an aborted flow must not leak the parked request"
);
assert!(app.collections[ci].entries.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_workspace_collection_rejects_paths_escaping_the_root() {
let dir = workspace_temp_dir("newreq_escape");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.create_workspace_collection(ci, "../evil".to_string());
assert_eq!(
app.collections[ci].path, None,
"a `..` path is rejected, not created"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn renaming_a_workspace_tab_survives_loading_a_different_collection_within_it() {
let dir = workspace_temp_dir("rename_survives");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.focus = Pane::Tabs;
app.open_prompt_rename();
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
*editor = super::editor::Editor::new("My Renamed Workspace", false);
}
press(&mut app, KeyCode::Enter);
assert_eq!(app.collections[ci].name, "My Renamed Workspace");
app.load_workspace_file(ci, dir.join("alpha.hurl"));
assert_eq!(
app.collections[ci].name, "My Renamed Workspace",
"renaming persists across picking a file"
);
assert_eq!(app.collections[ci].path, Some(dir.join("alpha.hurl")));
app.load_workspace_file(ci, dir.join("sub").join("beta.json"));
assert_eq!(
app.collections[ci].name, "My Renamed Workspace",
"renaming persists across switching collections"
);
assert_eq!(
app.collections[ci].path,
Some(dir.join("sub").join("beta.json"))
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn list_panel_title_tracks_the_loaded_collection_while_the_tab_bar_keeps_the_tabs_own_name() {
use ratatui::{Terminal, backend::TestBackend};
let dir = workspace_temp_dir("list_title");
let mut col = Collection::new("My Workspace".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_auto_prompt_dismissed = true;
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.load_workspace_file(ci, dir.join("alpha.hurl"));
let mut term = Terminal::new(TestBackend::new(160, 40)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains("My Workspace"),
"the tab bar shows the tab's own (renameable) name"
);
assert!(
text.contains("alpha"),
"the List panel title shows the currently loaded collection's name"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_git_downloaded_workspace_tab_shows_both_the_git_and_folder_icons() {
use ratatui::{Terminal, backend::TestBackend};
let dir = workspace_temp_dir("git_ws_icons");
let mut col = Collection::new("my-repo".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_downloaded_from_git = true;
col.workspace_auto_prompt_dismissed = true;
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
let mut term = Terminal::new(TestBackend::new(160, 40)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(super::draw::GIT_ICON),
"the git-branch icon marks it as loaded from git"
);
assert!(
text.contains(super::draw::FOLDER_ICON),
"the folder icon marks it as a Workspace"
);
assert!(
text.contains(super::draw::GIT_ICON),
"the git-branch icon marks it as loaded from git"
);
assert!(
text.contains(super::draw::FOLDER_ICON),
"the folder icon marks it as a Workspace"
);
let git_pos = text.find(super::draw::GIT_ICON).unwrap();
let folder_pos = text.find(super::draw::FOLDER_ICON).unwrap();
let name_pos = text.find("my-repo").unwrap();
assert!(
git_pos < folder_pos && folder_pos < name_pos,
"icons appear together, git-icon first, right before the name"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn esc_cancels_the_picker_without_removing_the_still_empty_tab_and_stops_auto_reopen() {
let dir = workspace_temp_dir("esc_cancel");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
let ci = app.collections.len() - 1;
app.active_tab = ci;
app.overlay = Some(Overlay::WorkspacePicker(WorkspacePickerState::new(
ci,
dir.clone(),
true,
)));
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none());
assert!(app.collections.len() > ci, "the tab is not removed");
assert_eq!(app.collections[ci].path, None);
assert!(
app.collections[ci].workspace_auto_prompt_dismissed,
"cancelling stops the auto-reopen prompt"
);
app.maybe_auto_open_workspace_picker();
assert!(
app.overlay.is_none(),
"auto-open does not fight the user's cancel"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn global_w_key_opens_the_picker_only_for_workspace_bound_tabs() {
let dir = workspace_temp_dir("w_key");
let mut app = TuiApp::default();
press(&mut app, KeyCode::Char('w'));
assert!(app.overlay.is_none());
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_auto_prompt_dismissed = true; app.collections.push(col);
app.active_tab = app.collections.len() - 1;
press(&mut app, KeyCode::Char('w'));
assert!(
matches!(app.overlay, Some(Overlay::WorkspacePicker(_))),
"an explicit `w` press reopens the picker even after it was dismissed"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_open_pops_the_picker_for_a_fresh_workspace_tab_with_no_collection_chosen() {
let dir = workspace_temp_dir("auto_open");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
assert!(app.overlay.is_none());
app.maybe_auto_open_workspace_picker();
assert!(matches!(app.overlay, Some(Overlay::WorkspacePicker(_))));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn auto_open_never_steals_focus_from_an_already_open_overlay() {
let dir = workspace_temp_dir("auto_open_no_steal");
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
app.overlay = Some(Overlay::Help(0));
app.maybe_auto_open_workspace_picker();
assert!(
matches!(app.overlay, Some(Overlay::Help(0))),
"an existing overlay is left alone"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_bound_tabs_show_the_folder_icon_in_the_tab_bar_and_list_title() {
use ratatui::{Terminal, backend::TestBackend};
let dir = workspace_temp_dir("tab_icon");
let mut col = Collection::new("my-ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(super::draw::FOLDER_ICON),
"the folder icon marks the Workspace-bound tab"
);
assert!(text.contains("my-ws"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_bound_list_title_hints_the_w_shortcut_when_there_is_room_but_not_on_a_narrow_terminal()
{
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let s = Strings::for_language(&Language::English);
let dir = workspace_temp_dir("title_hint");
let mut col = Collection::new("my-ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_auto_prompt_dismissed = true;
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
let mut wide_term = Terminal::new(TestBackend::new(160, 40)).unwrap();
wide_term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let wide_text = buffer_text(wide_term.backend().buffer());
assert!(
wide_text.contains(&format!("w {}", s.foot_workspace)),
"the title bar shows the w-to-browse hint on a wide terminal"
);
let mut narrow_term = Terminal::new(TestBackend::new(40, 40)).unwrap();
narrow_term
.draw(|f| super::draw::draw(f, &mut app))
.unwrap();
let narrow_text = buffer_text(narrow_term.backend().buffer());
assert!(
!narrow_text.contains(&format!("w {}", s.foot_workspace)),
"the title bar hides the w-to-browse hint on a narrow terminal"
);
let mut plain_app = TuiApp::default();
let mut plain_term = Terminal::new(TestBackend::new(160, 40)).unwrap();
plain_term
.draw(|f| super::draw::draw(f, &mut plain_app))
.unwrap();
let plain_text = buffer_text(plain_term.backend().buffer());
assert!(
!plain_text.contains(&format!("w {}", s.foot_workspace)),
"a plain (non-Workspace) tab never shows the workspace hint"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_workspace_tab_with_no_collections_at_all_shows_the_empty_state_hint_instead_of_a_blank_list() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let dir = std::env::temp_dir().join(format!("paperboy_ws_empty_hint_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let s = Strings::for_language(&Language::English);
let mut col = Collection::new("my-ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_auto_prompt_dismissed = true; let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains(s.workspace_empty_state),
"the empty-state hint replaces the blank list"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_workspace_tab_with_no_file_loaded_still_shows_the_browsable_file_tree() {
use ratatui::{Terminal, backend::TestBackend};
let dir = workspace_temp_dir("browse_no_file");
let mut col = Collection::new("my-ws".to_string(), Vec::new());
col.workspace_root = Some(dir.clone());
col.workspace_auto_prompt_dismissed = true;
let mut app = TuiApp::default();
app.collections.push(col);
app.active_tab = app.collections.len() - 1;
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let text = buffer_text(term.backend().buffer());
assert!(
text.contains("alpha.hurl"),
"the root's collection files are listed even before one is opened"
);
assert!(
text.contains("sub"),
"subfolders are listed too, so the user can browse into them"
);
let _ = std::fs::remove_dir_all(&dir);
}
fn workspace_app(dir: &std::path::Path) -> (TuiApp, usize) {
let mut app = TuiApp::default();
let mut col = Collection::new("ws".to_string(), Vec::new());
col.workspace_root = Some(dir.to_path_buf());
app.collections.push(col);
let ci = app.collections.len() - 1;
(app, ci)
}
#[test]
fn workspace_rows_list_folders_and_collections_and_inline_the_open_collections_requests() {
use crate::collection::WsRow;
let dir = workspace_temp_dir("ws_rows");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
let rows = app.collections[ci].ws_rows();
assert!(matches!(&rows[0], WsRow::Folder(n) if n == "sub"));
assert!(matches!(&rows[1], WsRow::Collection { name, open: true, .. } if name == "alpha.hurl"));
assert!(matches!(rows[2], WsRow::Request(0)));
assert_eq!(rows.len(), 3);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn entering_a_workspace_subfolder_and_then_up_navigates_the_breadcrumb() {
use crate::collection::WsRow;
let dir = workspace_temp_dir("ws_breadcrumb");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
app.collections[ci].list_cursor = 0;
app.on_enter();
assert_eq!(
app.collections[ci].workspace_browse,
vec!["sub".to_string()]
);
let rows = app.collections[ci].ws_rows();
assert!(matches!(rows[0], WsRow::Up));
assert!(matches!(&rows[1], WsRow::Collection { name, open: false, .. } if name == "beta.json"));
app.collections[ci].list_cursor = 0;
app.on_enter();
assert!(app.collections[ci].workspace_browse.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn entering_the_open_collection_row_collapses_and_re_expands_its_requests() {
use crate::collection::WsRow;
let dir = workspace_temp_dir("ws_accordion");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
app.collections[ci].list_cursor = 1;
app.on_enter();
assert!(app.collections[ci].workspace_collapsed);
let rows = app.collections[ci].ws_rows();
assert!(
!rows.iter().any(|r| matches!(r, WsRow::Request(_))),
"a collapsed collection hides its request rows"
);
assert!(matches!(&rows[1], WsRow::Collection { open: false, .. }));
app.collections[ci].list_cursor = 1;
app.on_enter();
assert!(!app.collections[ci].workspace_collapsed);
assert!(
app.collections[ci]
.ws_rows()
.iter()
.any(|r| matches!(r, WsRow::Request(0)))
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn opening_a_different_collection_with_unsaved_edits_warns_before_switching() {
let dir = workspace_temp_dir("ws_switch_warn");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.collections[ci].entries[0].modified = true;
app.focus = Pane::List;
app.collections[ci].list_cursor = 0; app.on_enter();
app.collections[ci].list_cursor = 1; app.on_enter();
assert!(
matches!(app.overlay, Some(Overlay::WorkspaceSwitchUnsaved { ci: c, .. }) if c == ci),
"switching with unsaved edits raises the warning first"
);
assert_eq!(app.collections[ci].path, Some(dir.join("alpha.hurl")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discarding_at_the_workspace_switch_warning_loads_the_new_collection() {
let dir = workspace_temp_dir("ws_switch_discard");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.collections[ci].entries[0].modified = true;
app.focus = Pane::List;
app.collections[ci].list_cursor = 0;
app.on_enter(); app.collections[ci].list_cursor = 1;
app.on_enter();
press(&mut app, KeyCode::Down);
press(&mut app, KeyCode::Enter);
assert!(app.overlay.is_none());
assert_eq!(
app.collections[ci].path,
Some(dir.join("sub").join("beta.json")),
"the new collection was loaded, discarding the edit"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cancelling_the_workspace_switch_warning_keeps_the_current_collection_and_edits() {
let dir = workspace_temp_dir("ws_switch_cancel");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.collections[ci].entries[0].modified = true;
app.focus = Pane::List;
app.collections[ci].list_cursor = 0;
app.on_enter();
app.collections[ci].list_cursor = 1;
app.on_enter();
press(&mut app, KeyCode::Esc);
assert!(app.overlay.is_none());
assert_eq!(app.collections[ci].path, Some(dir.join("alpha.hurl")));
assert!(
app.collections[ci].entries[0].modified,
"the unsaved edit is preserved when the switch is cancelled"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_on_a_workspace_request_row_opens_the_edit_wizard() {
let dir = workspace_temp_dir("ws_edit_request");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
app.collections[ci].list_cursor = 2;
app.on_enter();
assert!(
matches!(app.overlay, Some(Overlay::NewRequest(_))),
"Enter on a request row opens the edit wizard"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn right_arrow_descends_into_a_highlighted_workspace_folder() {
let dir = workspace_temp_dir("ws_right");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
app.collections[ci].list_cursor = 0;
press(&mut app, KeyCode::Right);
assert_eq!(
app.collections[ci].workspace_browse,
vec!["sub".to_string()],
"Right on a folder row descends into it, like Enter"
);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
let request_row = app.collections[ci]
.ws_rows()
.iter()
.position(|r| matches!(r, crate::collection::WsRow::Request(_)))
.unwrap();
app.collections[ci].list_cursor = request_row;
press(&mut app, KeyCode::Right);
assert!(
app.collections[ci].workspace_browse.is_empty(),
"Right on a request row does not navigate the tree"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn right_arrow_expands_a_collapsed_collection_and_opens_a_different_one() {
use crate::collection::WsRow;
let dir = workspace_temp_dir("ws_right_collection");
let (mut app, ci) = workspace_app(&dir);
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.focus = Pane::List;
app.collections[ci].workspace_collapsed = true;
let alpha_row = app.collections[ci]
.ws_rows()
.iter()
.position(|r| matches!(r, WsRow::Collection { open: false, .. }))
.unwrap();
app.collections[ci].list_cursor = alpha_row;
press(&mut app, KeyCode::Right);
assert!(
!app.collections[ci].workspace_collapsed,
"Right on the collapsed loaded collection expands it"
);
assert!(
app.collections[ci]
.ws_rows()
.iter()
.any(|r| matches!(r, WsRow::Request(0))),
"its requests are visible again"
);
app.collections[ci].list_cursor = 0; press(&mut app, KeyCode::Right);
let beta_row = app.collections[ci]
.ws_rows()
.iter()
.position(|r| matches!(r, WsRow::Collection { .. }))
.unwrap();
app.collections[ci].list_cursor = beta_row;
press(&mut app, KeyCode::Right);
assert_eq!(
app.collections[ci].path,
Some(dir.join("sub").join("beta.json")),
"Right on a collapsed, unloaded collection opens it"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn always_save_preference_auto_saves_instead_of_prompting_on_a_workspace_switch() {
let dir = workspace_temp_dir("ws_always_save");
let (mut app, ci) = workspace_app(&dir);
app.always_save_when_prompted = true;
app.load_workspace_file(ci, dir.join("alpha.hurl"));
app.collections[ci].entries[0].url = "https://example.com/edited".to_string();
app.collections[ci].entries[0].modified = true;
app.focus = Pane::List;
app.collections[ci].list_cursor = 0;
app.on_enter();
app.collections[ci].list_cursor = 1;
app.on_enter();
assert!(
app.overlay.is_none(),
"with always-save on, no Save/Discard/Cancel prompt is shown"
);
assert_eq!(
app.collections[ci].path,
Some(dir.join("sub").join("beta.json")),
"the switch went through"
);
let saved = std::fs::read_to_string(dir.join("alpha.hurl")).unwrap();
assert!(
saved.contains("https://example.com/edited"),
"the edit was auto-saved to disk before switching"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_always_save_preference_toggles_from_the_preferences_menu_and_is_off_by_default() {
let mut app = TuiApp::default();
assert!(
!app.always_save_when_prompted,
"the preference is off by default"
);
app.overlay = Some(Overlay::Preferences(3));
press(&mut app, KeyCode::Enter);
assert!(app.always_save_when_prompted, "Enter toggles it on");
assert!(
matches!(app.overlay, Some(Overlay::Preferences(3))),
"the highlight stays on the toggle row"
);
press(&mut app, KeyCode::Char(' '));
assert!(!app.always_save_when_prompted, "Space toggles it back off");
}
#[test]
fn the_workspace_picker_popup_renders_its_tree_filter_state_and_footer_hint() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let dir = workspace_temp_dir("popup_render");
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::WorkspacePicker(WorkspacePickerState::new(
0,
dir.clone(),
true,
))),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(text.contains(s.workspace_picker_title));
assert!(
text.contains(s.workspace_filter_on),
"the current filter state is shown in the title"
);
assert!(text.contains("alpha.hurl"));
assert!(text.contains("sub"));
assert!(
!text.contains("notes.txt"),
"notes.txt is excluded by the .hurl/.json filter"
);
assert!(text.contains(s.workspace_picker_hint));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_workspace_picker_shows_a_no_files_message_when_nothing_matches() {
use crate::i18n::{Language, Strings};
use ratatui::{Terminal, backend::TestBackend};
let dir = std::env::temp_dir().join(format!("paperboy_ws_no_files_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("readme.txt"), "no collections here").unwrap();
let th = super::theme::theme(&Language::English);
let s = Strings::for_language(&Language::English);
let mut app = TuiApp {
overlay: Some(Overlay::WorkspacePicker(WorkspacePickerState::new(
0,
dir.clone(),
true,
))),
..Default::default()
};
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| super::draw::draw_overlay(f, &mut app, &s, &th))
.unwrap();
let text = buffer_text(term.backend().buffer());
assert!(text.contains(s.workspace_no_files));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn alt_f5_runs_all_entries_instead_of_a_single_one() {
let e1 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1:81/one".into(),
..Default::default()
};
let e2 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1:81/two".into(),
..Default::default()
};
let mut app = TuiApp::default();
app.collections
.push(Collection::new("t".to_string(), vec![e1, e2]));
app.active_tab = 1;
app.focus = Pane::Main;
app.on_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::ALT));
assert!(
app.response.lock().unwrap().loading,
"Alt+F5 must start the batch run"
);
assert_eq!(
app.pending_batch_runs.len(),
1,
"a receiver must be tracked for the running batch"
);
assert!(
app.collections[1]
.entries
.iter()
.all(|e| matches!(e.last_run, crate::hurl::RunStatus::Running)),
"every entry must be marked in-progress immediately, not just the selected one"
);
}
#[test]
fn run_all_entries_blocks_when_any_entry_references_a_still_pending_secret() {
use crate::environment::{EnvVar, Environment, ValueSource};
let e1 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1:81/plain".into(),
..Default::default()
};
let e2 = HurlEntry {
method: "GET".into(),
url: "http://192.0.2.1:81/{{ TOKEN }}".into(),
..Default::default()
};
let mut col = Collection::new("t".to_string(), vec![e1, e2]);
let env = Environment {
id: 0,
name: "e".into(),
vars: vec![EnvVar {
key: "TOKEN".into(),
value: "{{ op://x }}".into(),
source: ValueSource::OnePassword,
resolved: false,
loading: true,
original_value: "{{ op://x }}".into(),
modified: false,
user_added: false,
raw: String::new(),
}],
path: None,
git_origin: None,
};
let mut app = TuiApp::default();
let env_id = add_global_env(&mut app, env);
col.linked_env_id = Some(env_id);
app.collections.push(col);
app.run_all_entries(1);
assert!(
matches!(app.status, Some(crate::i18n::Status::WaitingSecrets(ref k)) if k == &vec!["TOKEN".to_string()]),
"must block with the pending key named"
);
assert!(
app.pending_batch_runs.is_empty(),
"no background run must be started while blocked"
);
assert!(!app.response.lock().unwrap().loading);
}
#[test]
fn poll_batch_run_updates_applies_pass_fail_markers_captures_and_summary() {
let e1 = HurlEntry {
title: "one".into(),
..Default::default()
};
let e2 = HurlEntry {
title: "two".into(),
..Default::default()
};
let mut e3 = HurlEntry {
title: "three".into(),
..Default::default()
};
e3.last_response = Some(crate::http::ApiResponse {
status: 418,
..Default::default()
});
let col = Collection::new("t".to_string(), vec![e1, e2, e3]);
let col_id = col.id;
let mut app = TuiApp::default();
app.collections.push(col);
let (tx, rx) = std::sync::mpsc::channel();
let mut captures = std::collections::HashMap::new();
captures.insert("token".to_string(), "abc".to_string());
let responses = vec![
Some(crate::http::ApiResponse {
status: 200,
body: "one-body".into(),
..Default::default()
}),
Some(crate::http::ApiResponse {
status: 500,
body: "two-body".into(),
..Default::default()
}),
None,
];
tx.send(crate::request::BatchRunUpdate {
col_id,
results: vec![Some(true), Some(false), None],
captures,
responses,
})
.unwrap();
drop(tx); app.pending_batch_runs.push(rx);
for _ in 0..3 {
app.poll_batch_run_updates();
}
let col = &app.collections[1];
assert!(matches!(
col.entries[0].last_run,
crate::hurl::RunStatus::Passed
));
assert!(matches!(
col.entries[1].last_run,
crate::hurl::RunStatus::Failed
));
assert!(
matches!(col.entries[2].last_run, crate::hurl::RunStatus::NotRun),
"an entry the runner never reached goes back to not-run, not stuck Running"
);
assert_eq!(
col.entries[0]
.last_response
.as_ref()
.map(|r| r.body.as_ref()),
Some("one-body"),
"each entry remembers its own response, not just the last entry's"
);
assert_eq!(
col.entries[1]
.last_response
.as_ref()
.map(|r| r.body.as_ref()),
Some("two-body")
);
assert_eq!(
col.entries[2].last_response.as_ref().map(|r| r.status),
Some(418),
"an unreached entry keeps its previous response instead of losing it"
);
assert_eq!(
col.captures.get("token").unwrap(),
"abc",
"captures merged into the collection"
);
assert!(
matches!(
app.status,
Some(crate::i18n::Status::CollectionRunSummary {
passed: 1,
failed: 1,
total: 2
})
),
"summary counts only entries the runner actually reached"
);
assert!(
app.pending_batch_runs.is_empty(),
"the one-shot receiver must be dropped once drained"
);
}
#[test]
fn wrapped_row_count_matches_wrap_line_for_various_lengths() {
use super::draw::{wrap_line, wrapped_row_count};
use ratatui::text::Line;
for (len, width) in [(0, 10), (1, 10), (10, 10), (11, 10), (25, 10), (7, 0)] {
let text = "x".repeat(len);
let actual = wrap_line(Line::raw(text), width).len();
assert_eq!(
wrapped_row_count(len, width),
actual,
"len={len} width={width}"
);
}
}
#[test]
fn a_huge_response_body_still_scrolls_and_clamps_correctly() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
let body: String = (0..50_000).map(|i| format!("line {i}\n")).collect();
app.collections[ci].entries = vec![HurlEntry {
title: "huge".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
app.resp_scroll = u16::MAX;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
assert_eq!(
app.resp_scroll, app.resp_max_scroll,
"scroll must clamp to the last-line boundary"
);
let wrap = app
.resp_wrap
.as_ref()
.expect("a huge response body must populate the wrap cache");
let visible = wrap.visible_window(app.resp_scroll, app.resp_text_area.height);
assert!(
!visible.is_empty(),
"the clamped scroll position must still show real content"
);
let last_visible: String = visible
.last()
.unwrap()
.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect();
assert_eq!(
last_visible, "line 49999",
"clamped scroll must land exactly on the last line"
);
}
#[test]
fn dragging_far_outside_the_terminal_bounds_does_not_panic_or_break_rendering() {
use ratatui::crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend};
let mut app = TuiApp::default();
let ci = app.active_tab;
app.collections[ci].entries = vec![HurlEntry {
title: "sel".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: (0..200)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n")
.into(),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
let mut term = Terminal::new(TestBackend::new(60, 20)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let area = app.resp_text_area;
assert!(area.width > 0 && area.height > 1);
let ev = |kind, col: u16, row: u16| MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
};
app.on_mouse(ev(
MouseEventKind::Down(MouseButton::Left),
area.x + 2,
area.y,
));
for row in [0u16, 5, 200, 1000, 5000, 30000, 60000, u16::MAX] {
for col in [0u16, 5, 100, 1000, 40000, u16::MAX] {
app.on_mouse(ev(MouseEventKind::Drag(MouseButton::Left), col, row));
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
}
app.on_mouse(ev(MouseEventKind::Up(MouseButton::Left), area.x, area.y));
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
#[test]
fn a_single_enormous_line_response_with_an_active_selection_redraws_quickly() {
use ratatui::{Terminal, backend::TestBackend};
use std::time::{Duration, Instant};
let mut app = TuiApp::default();
let ci = app.active_tab;
let body: String = "x".repeat(5_000_000);
app.collections[ci].entries = vec![HurlEntry {
title: "giant-line".into(),
last_response: Some(crate::http::ApiResponse {
status: 200,
status_text: "OK".into(),
body: Arc::from(body.as_str()),
..Default::default()
}),
..Default::default()
}];
app.focus = Pane::Response;
app.text_selection = Some(TextSelection {
pane: Pane::Response,
anchor: TextPos::new(0, 0),
cursor: TextPos::new(0, 3),
});
let mut term = Terminal::new(TestBackend::new(80, 30)).unwrap();
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
let start = Instant::now();
for _ in 0..100 {
term.draw(|f| super::draw::draw(f, &mut app)).unwrap();
}
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(3),
"100 redraws of a 5MB single-line response with a selection took {elapsed:?} — expected a small fraction of a second"
);
}
#[test]
fn git_wizard_loading_popup_is_wide_enough_for_the_full_title_even_with_a_short_message() {
use ratatui::{Terminal, backend::TestBackend};
let mut w = RemoteWizard::new(RemoteKind::Collection, Vec::new());
w.stage = RemoteStage::Loading {
phase: LoadPhase::File,
};
let s = crate::i18n::Strings::for_language(&Language::English);
let th = crate::tui::theme::theme(&Language::English);
let mut term = Terminal::new(TestBackend::new(120, 30)).unwrap();
term.draw(|f| draw_remote_wizard(f, &w, &s, &th)).unwrap();
let buf = term.backend().buffer().clone();
let mut top_border = String::new();
for x in 0..120 {
top_border.push_str(buf.cell((x, 13)).map(|c| c.symbol()).unwrap_or(" "));
}
assert!(
top_border.contains("Load Collection from Git…"),
"expected the full title on the popup's top border, got: {top_border:?}"
);
}
#[test]
fn begin_save_workspace_as_on_a_non_workspace_tab_shows_a_status_message() {
use crate::i18n::Status;
let mut app = TuiApp::default();
assert!(app.collections[0].workspace_root.is_none());
app.begin_save_workspace_as();
assert!(matches!(app.status, Some(Status::NotWorkspace)));
assert!(app.pending_workspace_save.is_none());
assert!(
app.overlay.is_none(),
"no browser is opened for a non-Workspace tab"
);
}
#[test]
fn save_workspace_flow_copies_files_and_rebinds_a_local_workspace_tab_leaving_the_original_folder_alone()
{
use crate::i18n::Status;
let src = workspace_temp_dir("save_local_src");
let dest_parent = std::env::temp_dir().join(format!(
"paperboy_ws_save_dest_parent_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dest_parent);
std::fs::create_dir_all(&dest_parent).unwrap();
let mut app = TuiApp::default();
app.confirm_workspace_root(src.clone());
let ci = app.active_tab;
app.load_workspace_file(ci, src.join("alpha.hurl"));
app.overlay = None;
let original_name = app.collections[ci].name.clone();
app.begin_save_workspace_as();
match &app.pending_workspace_save {
Some(p) => {
assert_eq!(p.source_root, src);
assert_eq!(p.default_name, original_name);
assert!(matches!(p.target, WorkspaceSaveTarget::ExistingTab(idx) if idx == ci));
}
None => panic!("expected a pending save to be set up"),
}
assert!(matches!(
&app.overlay,
Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, _))
));
app.last_browse_dir = Some(dest_parent.clone());
app.overlay = Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, {
let mut ex = ratatui_explorer::FileExplorer::new().unwrap();
let _ = ex.set_cwd(&dest_parent);
Box::new(ex)
}));
press(&mut app, KeyCode::Char(' '));
match &app.overlay {
Some(Overlay::Prompt {
kind: PromptKind::WorkspaceSaveName,
editor,
..
}) => {
assert_eq!(
editor.text(),
original_name,
"the name prompt defaults to the tab's own name"
);
}
_ => panic!("the name prompt should open next"),
}
press(&mut app, KeyCode::Enter);
let new_root = app.collections[ci]
.workspace_root
.clone()
.expect("root still set");
assert!(
new_root.starts_with(&dest_parent),
"tab rebound to the new destination"
);
assert!(new_root.join("alpha.hurl").exists());
assert!(new_root.join("sub").join("beta.json").exists());
assert_eq!(
app.collections[ci].path.as_deref(),
Some(new_root.join("alpha.hurl")).as_deref(),
"the previously-selected file is re-resolved at the new location"
);
assert!(!app.collections[ci].workspace_downloaded_from_git);
assert!(app.collections[ci].workspace_git_origin.is_none());
assert!(matches!(app.status, Some(Status::WorkspaceSaved)));
assert!(app.pending_workspace_save.is_none());
assert!(
src.join("alpha.hurl").exists(),
"a plain local folder is copied, never moved/deleted"
);
let _ = std::fs::remove_dir_all(&src);
let _ = std::fs::remove_dir_all(&dest_parent);
}
#[test]
fn save_workspace_flow_cleans_up_the_old_temp_folder_when_it_was_a_git_download() {
let src = workspace_temp_dir("save_git_src");
let dest_parent =
std::env::temp_dir().join(format!("paperboy_ws_save_git_dest_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dest_parent);
std::fs::create_dir_all(&dest_parent).unwrap();
let mut app = TuiApp::default();
app.confirm_workspace_root_from_git(src.clone(), "myrepo".to_string(), None);
let ci = app.active_tab;
assert!(app.collections[ci].workspace_downloaded_from_git);
app.overlay = None;
app.begin_save_workspace_as();
app.last_browse_dir = Some(dest_parent.clone());
app.overlay = Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, {
let mut ex = ratatui_explorer::FileExplorer::new().unwrap();
let _ = ex.set_cwd(&dest_parent);
Box::new(ex)
}));
press(&mut app, KeyCode::Char(' '));
press(&mut app, KeyCode::Enter);
let new_root = app.collections[ci].workspace_root.clone().unwrap();
assert!(new_root.join("alpha.hurl").exists());
assert!(!app.collections[ci].workspace_downloaded_from_git);
assert!(
!src.exists(),
"the old temp git-download folder is cleaned up after a successful save"
);
let _ = std::fs::remove_dir_all(&new_root);
let _ = std::fs::remove_dir_all(&dest_parent);
}
#[test]
fn workspace_storage_choice_choose_a_folder_leads_to_a_brand_new_plain_tab_and_cleans_up_the_temp_download()
{
let repo = workspace_temp_dir("storage_choice_choose");
let dest_parent = std::env::temp_dir().join(format!(
"paperboy_ws_storage_choice_dest_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dest_parent);
std::fs::create_dir_all(&dest_parent).unwrap();
let mut app = TuiApp::default();
let before = app.collections.len();
app.overlay = Some(Overlay::WorkspaceStorageChoice {
repo: repo.clone(),
name: "myrepo".to_string(),
origin: None,
sel: 0,
});
press(&mut app, KeyCode::Right);
press(&mut app, KeyCode::Enter);
assert!(
matches!(
&app.overlay,
Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, _))
),
"choosing a folder opens the destination browser"
);
assert_eq!(
app.collections.len(),
before,
"no tab is created until the save actually completes"
);
app.last_browse_dir = Some(dest_parent.clone());
app.overlay = Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, {
let mut ex = ratatui_explorer::FileExplorer::new().unwrap();
let _ = ex.set_cwd(&dest_parent);
Box::new(ex)
}));
press(&mut app, KeyCode::Char(' '));
press(&mut app, KeyCode::Enter);
assert_eq!(
app.collections.len(),
before + 1,
"a brand new plain Workspace tab is created"
);
let ci = app.active_tab;
let new_root = app.collections[ci].workspace_root.clone().unwrap();
assert!(new_root.starts_with(&dest_parent));
assert!(new_root.join("alpha.hurl").exists());
assert!(
!app.collections[ci].workspace_downloaded_from_git,
"a saved-to-a-chosen-folder tab is a plain Workspace, not flagged as a temp git download"
);
assert!(
!repo.exists(),
"the original temp download directory is cleaned up"
);
let _ = std::fs::remove_dir_all(&new_root);
let _ = std::fs::remove_dir_all(&dest_parent);
}
#[test]
fn cancelling_the_destination_browser_falls_back_to_keeping_a_git_workspace_temporary() {
let repo = workspace_temp_dir("storage_choice_cancel");
let mut app = TuiApp::default();
let before = app.collections.len();
app.overlay = Some(Overlay::WorkspaceStorageChoice {
repo: repo.clone(),
name: "myrepo".to_string(),
origin: None,
sel: 1,
});
press(&mut app, KeyCode::Enter);
assert!(matches!(
&app.overlay,
Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, _))
));
press(&mut app, KeyCode::Esc);
assert_eq!(
app.collections.len(),
before + 1,
"the download is not lost — it falls back to a temporary tab"
);
let ci = app.active_tab;
assert_eq!(
app.collections[ci].workspace_root.as_deref(),
Some(repo.as_path())
);
assert!(
app.collections[ci].workspace_downloaded_from_git,
"falls back to the old temporary behaviour"
);
assert!(app.pending_workspace_save.is_none());
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn an_empty_name_at_the_save_prompt_also_falls_back_to_keeping_the_workspace_temporary() {
let repo = workspace_temp_dir("storage_choice_empty_name");
let dest_parent = std::env::temp_dir().join(format!(
"paperboy_ws_storage_choice_empty_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dest_parent);
std::fs::create_dir_all(&dest_parent).unwrap();
let mut app = TuiApp::default();
let before = app.collections.len();
app.overlay = Some(Overlay::WorkspaceStorageChoice {
repo: repo.clone(),
name: "myrepo".to_string(),
origin: None,
sel: 1,
});
press(&mut app, KeyCode::Enter);
app.last_browse_dir = Some(dest_parent.clone());
app.overlay = Some(Overlay::Browser(FileAction::SaveWorkspaceChooseFolder, {
let mut ex = ratatui_explorer::FileExplorer::new().unwrap();
let _ = ex.set_cwd(&dest_parent);
Box::new(ex)
}));
press(&mut app, KeyCode::Char(' '));
if let Some(Overlay::Prompt { editor, .. }) = &mut app.overlay {
for _ in 0..editor.text().chars().count() {
editor.backspace();
}
}
press(&mut app, KeyCode::Enter);
assert_eq!(
app.collections.len(),
before + 1,
"still falls back to a temporary tab, never losing the download"
);
let ci = app.active_tab;
assert_eq!(
app.collections[ci].workspace_root.as_deref(),
Some(repo.as_path())
);
assert!(app.collections[ci].workspace_downloaded_from_git);
let _ = std::fs::remove_dir_all(&repo);
let _ = std::fs::remove_dir_all(&dest_parent);
}
#[test]
fn env_name_keeps_dotted_suffix_but_still_drops_a_real_extension() {
assert_eq!(
env_name_from_path("/x/.env.dev-au", "environment"),
".env.dev-au"
);
assert_eq!(env_name_from_path("/x/.env", "environment"), ".env");
assert_eq!(env_name_from_path("/x/prod.vars", "environment"), "prod");
assert_eq!(env_name_from_path("", "environment"), "environment");
}
#[test]
fn collection_name_hides_only_known_extensions() {
assert_eq!(
collection_name_from_path("/x/api.hurl", "collection"),
"api"
);
assert_eq!(
collection_name_from_path("/x/api.json", "collection"),
"api"
);
assert_eq!(
collection_name_from_path("/x/api.HURL", "collection"),
"api"
);
assert_eq!(
collection_name_from_path("/x/env.dev-au", "collection"),
"env.dev-au"
);
assert_eq!(
collection_name_from_path("/x/notes.txt", "collection"),
"notes.txt"
);
}