Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240/// Binary-crate unit tests.////// Declared in `main.rs` as `#[cfg(test)] mod tests;` so that private items/// from `commands`, `input`, and `cli` are all in scope via `super::`.use std::path::PathBuf;use tui_textarea::TextArea;
use crossterm::event::{KeyCode, KeyModifiers};use ratatui::layout::Rect;
use yame::app::App;use yame::config::Theme;use yame::decoration::DecorationMap;use yame::status::{StatusLine, StatusMode};
use super::commands::{clamp_scroll, handle_exit};use super::input::{handle_goto_line_key, handle_pair_wrap};use yame::app::get_selection_text;
fn make_app() -> App { App { textarea: TextArea::default(), file_path: Some(PathBuf::from("test.md")), is_dirty: false, saved_content: None, theme: Theme::default_theme(), italic_support: false, powerline_glyphs: false, last_keystroke: None, force_redecorate: false, decoration_map: DecorationMap::default(), word_count: 0, status: StatusLine::default(), config_warnings: vec![], scroll_top: 0, free_scroll: false, sticky_col: None, content_width: 0, clipboard: yame::app::ClipboardState::Uninitialized, shortened_path: "test.md".to_string(), tab_width: 4, highlight_cache: None, file_mode: yame::app::FileMode::Markdown, show_line_numbers: false, search: None, search_last_typed: None, typewriter_mode: false, focus_mode: false, show_shortcuts: false, read_only: false, auto_close_pairs: false, autosave_secs: 0, settings: None, }}
#[test]fn handle_exit_clean_returns_true() { let mut app = make_app(); app.is_dirty = false; assert!(handle_exit(&mut app), "clean file should exit immediately"); assert!( matches!(app.status.mode, StatusMode::Normal), "status unchanged for clean exit" );}
#[test]fn handle_exit_dirty_returns_false_and_prompts() { let mut app = make_app(); app.is_dirty = true; assert!(!handle_exit(&mut app), "dirty file must not exit"); assert!( matches!(app.status.mode, StatusMode::ExitPrompt), "dirty exit must show ExitPrompt" );}
// ── clamp_scroll tests ────────────────────────────────────────────────────// TEST_COL=82 → content_width = 82 - GUTTER - GUTTER = 80.// make_app_with_lines pre-sets content_width=80 to match TEST_COL-based tests.// Tests that exercise wrapping at a narrower width override content_width inline.const TEST_COL: u16 = 82;const TEST_CW: usize = 80; // TEST_COL - 2*GUTTER
fn make_editor_area(height: u16) -> Rect { Rect { x: 0, y: 0, width: TEST_COL, height, }}
fn make_app_with_lines(lines: &[&str]) -> App { let mut app = make_app(); app.textarea = TextArea::new(lines.iter().map(|s| s.to_string()).collect()); app.content_width = TEST_CW; app}
#[test]fn clamp_scroll_cursor_above_viewport_scrolls_up() { let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 5; app.textarea .move_cursor(tui_textarea::CursorMove::Jump(2, 0)); clamp_scroll(&mut app, make_editor_area(10), 0); assert_eq!( app.scroll_top, 2, "cursor above viewport → scroll_top = cursor_row" );}
#[test]fn clamp_scroll_cursor_in_viewport_unchanged() { let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 0; app.textarea .move_cursor(tui_textarea::CursorMove::Jump(3, 0)); clamp_scroll(&mut app, make_editor_area(10), 0); assert_eq!( app.scroll_top, 0, "cursor inside viewport → scroll_top unchanged" );}
#[test]fn clamp_scroll_cursor_at_bottom_with_padding_scrolls_down() { let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 0; app.textarea .move_cursor(tui_textarea::CursorMove::Jump(9, 0)); clamp_scroll(&mut app, make_editor_area(10), 3); assert!( app.scroll_top > 0, "cursor near bottom with padding → scroll_top advances" );}
#[test]fn clamp_scroll_zero_height_does_not_panic() { let mut app = make_app_with_lines(&["a"; 5]); clamp_scroll(&mut app, make_editor_area(0), 3);}
// col_width=4 → cw = 4 − 2×GUTTER(1) = 2.// col_width=5 → cw = 5 − 2×GUTTER(1) = 3.// (make_editor_area only provides height; col_width is a separate parameter.)
#[test]fn clamp_scroll_scroll_down_exact_new_top() { // 5 lines of "aa" (1 visual row each at cw=2). Cursor at row 4, visible=3, padding=0. // above_visual=4, cursor_visual=4 ≥ 3 → scroll down. // headroom=2 → walk backward through rows 3 and 2 (1 row each) → scroll_top=2. // Kills: ln66 *→+ (wider cw=1 → 2 rows/line → scroll_top=3), ln99 +→* (0≥3?=no → // no scroll), ln103 >→< (loop never runs → scroll_top=4), // ln106 >→== / >→< (underflow), >→>= (breaks one step early → scroll_top=3), // ln110 -=→+= and -=→/= (remaining never shrinks → scroll_top=0). let mut app = make_app_with_lines(&["aa"; 5]); app.content_width = 2; // col_width=4 - 2*GUTTER = 2: "aa" fills one row, no wrap app.textarea .move_cursor(tui_textarea::CursorMove::Jump(4, 0)); clamp_scroll(&mut app, make_editor_area(3), 0); assert_eq!(app.scroll_top, 2, "walk-backward must land on row 2");}
#[test]fn clamp_scroll_walk_backward_reads_prev_line() { // ["aaaa","a","aaaa","a","aa"] at cw=2: "aaaa"→2 rows, rest→1 row. // cursor at row 4; above_visual = 2+1+2+1 = 6 ≥ 3 → scroll. // headroom=2; walk: row-3("a")=1≤2 → consume, row-2("aaaa")=2>1 → break. // scroll_top=3. // Kills: ln105 -→+ (reads row+1=wrong wrap count → scroll_top=2), // ln105 -→/ (reads same row → wrong count → scroll_top=2). let mut app = make_app_with_lines(&["aaaa", "a", "aaaa", "a", "aa"]); app.content_width = 2; // col_width=4 - 2*GUTTER = 2: "aaaa" wraps to 2 rows app.textarea .move_cursor(tui_textarea::CursorMove::Jump(4, 0)); clamp_scroll(&mut app, make_editor_area(3), 0); assert_eq!( app.scroll_top, 3, "must read line new_top-1, not new_top or new_top+1" );}
#[test]fn clamp_scroll_cursor_in_subrow0_no_scroll() { // "aaaaa" at cw=3 → ["aaa","aa"] (2 sub-rows). Cursor col=0 → sub-row 0. // above_visual=2, cursor_visual=2, visible=3 → no scroll needed (2 < 3). // Mutations that wrongly compute sub-row as 1 push cursor_visual to 3 → spurious scroll. // Kills: ln88 +→* (char_end=char_start*char_len: first chunk end=0 → cursor falls // through to last chunk → sub-row 1), ln89 ||→&& (both conditions needed: // last-chunk fallback fires at chunk 1 → sub-row 1), // ln89 <→== and <→> (cursor_col=0 never ==/>char_end → last fallback → sub-row 1). let mut app = make_app_with_lines(&["a", "a", "aaaaa"]); app.content_width = 3; // col_width=5 - 2*GUTTER = 3: "aaaaa" wraps to ["aaa","aa"] app.textarea .move_cursor(tui_textarea::CursorMove::Jump(2, 0)); clamp_scroll(&mut app, make_editor_area(3), 0); assert_eq!( app.scroll_top, 0, "cursor in sub-row 0 must not trigger scroll" );}
#[test]fn clamp_scroll_cursor_in_subrow1_exact() { // Same layout; cursor col=3 → in second chunk ["aaa","aa"]: char_end of chunk 0 = 3, // so cursor_col=3 is NOT < 3 → falls to chunk 1 → sub-row 1. // cursor_visual=3, visible=3 → scroll. headroom=1 → scroll_top=1. // Kills: ln89 ==→!= (disables last-chunk fallback; sub-row stays 0 → no scroll → top=0), // ln89 <→<= (cursor_col=3 ≤ char_end=3 → sub-row 0 → no scroll → top=0). let mut app = make_app_with_lines(&["a", "a", "aaaaa"]); app.content_width = 3; // col_width=5 - 2*GUTTER = 3: "aaaaa" wraps to ["aaa","aa"] app.textarea .move_cursor(tui_textarea::CursorMove::Jump(2, 3)); clamp_scroll(&mut app, make_editor_area(3), 0); assert_eq!( app.scroll_top, 1, "cursor in sub-row 1 must scroll to expose it" );}
#[test]fn clamp_scroll_padding_affects_headroom() { // 5 "aa" lines (1 row each at cw=2), cursor row 4, visible=6, bottom_padding=2. // cursor_visual=4; 4+2=6 ≥ 6 → scroll. // headroom = 6 − 1 − 0 − 2 = 3 → walk rows 3,2,1 → scroll_top=1. // Kills: ln100 +→- (1+0-2 underflows usize in debug → panic), // ln100 +→* (1+0*2=1 → headroom=5 → walks all 4 rows → scroll_top=0). let mut app = make_app_with_lines(&["aa"; 5]); app.content_width = 2; // col_width=4 - 2*GUTTER = 2 app.textarea .move_cursor(tui_textarea::CursorMove::Jump(4, 0)); clamp_scroll(&mut app, make_editor_area(6), 2); assert_eq!(app.scroll_top, 1, "padding must reduce headroom correctly");}
// ── get_selection_text tests ──────────────────────────────────────────────
fn make_key(code: KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)}
#[test]fn get_selection_text_none_when_no_selection() { let app = make_app_with_lines(&["hello world"]); assert_eq!(get_selection_text(&app), None);}
#[test]fn get_selection_text_single_line() { let mut app = make_app_with_lines(&["hello world"]); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } assert_eq!(get_selection_text(&app), Some("hello".to_string()));}
#[test]fn get_selection_text_multiline() { let mut app = make_app_with_lines(&["abc", "def"]); app.textarea.start_selection(); app.textarea.move_cursor(tui_textarea::CursorMove::Down); app.textarea.move_cursor(tui_textarea::CursorMove::End); let text = get_selection_text(&app).unwrap_or_default(); assert!( text.contains('\n'), "multiline selection must include newline" ); assert!(text.starts_with("abc"), "first line preserved");}
// ── handle_pair_wrap tests ────────────────────────────────────────────────
#[test]fn pair_wrap_bracket_wraps_selection() { let mut app = make_app_with_lines(&["hello"]); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } let handled = handle_pair_wrap(&mut app, make_key(KeyCode::Char('['))); assert!(handled, "pair wrap must return true when selection present"); let line = app.textarea.lines()[0].clone(); assert_eq!(line, "[hello]", "selection wrapped with square brackets");}
#[test]fn pair_wrap_star_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); app.textarea.start_selection(); for _ in 0..2 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } handle_pair_wrap(&mut app, make_key(KeyCode::Char('*'))); assert_eq!(app.textarea.lines()[0], "*hi*");}
#[test]fn pair_wrap_no_selection_returns_false() { let mut app = make_app_with_lines(&["hello"]); let handled = handle_pair_wrap(&mut app, make_key(KeyCode::Char('['))); assert!(!handled, "no selection → pair wrap is a no-op"); assert_eq!(app.textarea.lines()[0], "hello", "content unchanged");}
#[test]fn pair_wrap_non_pair_key_returns_false() { let mut app = make_app_with_lines(&["hello"]); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } let handled = handle_pair_wrap(&mut app, make_key(KeyCode::Char('a'))); assert!(!handled, "non-pair key → pair wrap is a no-op");}
#[test]fn pair_wrap_ctrl_chord_ignored() { let mut app = make_app_with_lines(&["hello"]); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } let k = crossterm::event::KeyEvent::new(KeyCode::Char('['), KeyModifiers::CONTROL); let handled = handle_pair_wrap(&mut app, k); assert!(!handled, "Ctrl chord must not trigger pair wrap");}
// ── free_scroll / decoupled scroll tests ─────────────────────────────────
use super::input::is_navigation_key;
fn ctrl_key(code: KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)}
#[test]fn scroll_down_increases_scroll_top_without_moving_cursor() { // Simulate what the ScrollDown handler does: increment scroll_top, set free_scroll. let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 0; let (cursor_before, _) = app.textarea.cursor(); let max = app.textarea.lines().len().saturating_sub(1); app.scroll_top = (app.scroll_top + 3).min(max); app.free_scroll = true; let (cursor_after, _) = app.textarea.cursor(); assert_eq!(app.scroll_top, 3, "scroll_top advanced by SCROLL_LINES"); assert_eq!( cursor_before, cursor_after, "cursor must not move on scroll" ); assert!(app.free_scroll, "free_scroll must be set");}
#[test]fn scroll_up_decreases_scroll_top_without_moving_cursor() { let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 6; let (cursor_before, _) = app.textarea.cursor(); app.scroll_top = app.scroll_top.saturating_sub(3); app.free_scroll = true; let (cursor_after, _) = app.textarea.cursor(); assert_eq!(app.scroll_top, 3, "scroll_top decreased by SCROLL_LINES"); assert_eq!( cursor_before, cursor_after, "cursor must not move on scroll" ); assert!(app.free_scroll, "free_scroll must be set");}
#[test]fn scroll_up_saturates_at_zero() { let mut app = make_app_with_lines(&["a"; 10]); app.scroll_top = 1; app.scroll_top = app.scroll_top.saturating_sub(5); // would go negative assert_eq!(app.scroll_top, 0, "scroll_top must not go below 0");}
#[test]fn scroll_down_saturates_at_last_line() { let mut app = make_app_with_lines(&["a"; 5]); let max = app.textarea.lines().len().saturating_sub(1); // = 4 app.scroll_top = (app.scroll_top + 100).min(max); assert_eq!(app.scroll_top, 4, "scroll_top must not exceed last line");}
#[test]fn ctrl_up_is_navigation_key() { let k = ctrl_key(KeyCode::Up); assert!( is_navigation_key(&k), "Ctrl+Up must be classified as navigation (no debounce)" );}
#[test]fn ctrl_down_is_navigation_key() { let k = ctrl_key(KeyCode::Down); assert!( is_navigation_key(&k), "Ctrl+Down must be classified as navigation (no debounce)" );}
#[test]fn plain_up_is_navigation_key() { // Regression: plain Up must still be navigation after is_navigation_key refactor. let k = make_key(KeyCode::Up); assert!( is_navigation_key(&k), "plain Up must remain a navigation key" );}
#[test]fn ctrl_z_is_not_navigation_key() { // Ctrl+Z is an edit key (undo), not navigation — no debounce skip. let k = ctrl_key(KeyCode::Char('z')); assert!( !is_navigation_key(&k), "Ctrl+Z must not be a navigation key" );}
#[test]fn char_a_is_not_navigation_key() { // Ordinary characters are not navigation keys. let k = make_key(KeyCode::Char('a')); assert!( !is_navigation_key(&k), "char 'a' must not be a navigation key" );}
// ── shell_init_str tests ──────────────────────────────────────────────────
#[test]fn shell_init_str_contains_function_declaration() { let s = super::cli::shell_init_str("bash"); assert!( s.contains("yame()"), "output must declare a yame() function" );}
#[test]fn shell_init_str_contains_fd_fzf_guard() { let s = super::cli::shell_init_str("bash"); assert!( s.contains("command -v fd") && s.contains("command -v fzf"), "output must guard on fd and fzf availability" );}
#[test]fn shell_init_str_uses_command_yame_passthrough() { let s = super::cli::shell_init_str("bash"); assert!( s.contains("command yame"), "output must use 'command yame' to bypass the wrapper" );}
#[test]fn shell_init_str_tier2_includes_hidden_flag() { let s = super::cli::shell_init_str("zsh"); assert!( s.contains("--hidden"), "tier-2 search must include --hidden so dotfiles can be found" );}
#[test]fn shell_init_str_excludes_correct_target_dir() { // Must use -E "target" (correct), not -E "target/*" (wrong glob). let s = super::cli::shell_init_str("bash"); assert!( s.contains(r#"-E "target""#), r#"must exclude target directory with -E "target""# ); assert!( !s.contains(r#"-E "target/*""#), r#"must NOT use the wrong glob form -E "target/*""# );}
#[test]fn shell_init_str_fallback_prompts_before_creating() { let s = super::cli::shell_init_str("zsh"); assert!( s.contains("printf"), "fallback must display a prompt before creating a new file" ); assert!( s.contains("[y/N]"), "fallback prompt must show [y/N] confirmation" );}
#[test]fn shell_init_str_bash_and_zsh_produce_same_output() { assert_eq!( super::cli::shell_init_str("bash"), super::cli::shell_init_str("zsh"), "bash and zsh init output must be identical (single function body)" );}
// ── version_string tests ──────────────────────────────────────────────────
#[test]fn version_string_starts_with_yame() { let v = super::cli::version_string(); assert!( v.starts_with("yame "), "version string must start with 'yame '" );}
#[test]fn version_string_contains_semver() { let v = super::cli::version_string(); // Must contain at least one dot (e.g. "0.1.0") after the "yame " prefix. let ver = v.strip_prefix("yame ").expect("must start with 'yame '"); assert!( ver.contains('.'), "version portion must be a semver string, got: {ver:?}" );}
#[test]fn version_string_matches_cargo_pkg_version() { let v = super::cli::version_string(); let expected = format!("yame {}", env!("CARGO_PKG_VERSION")); assert_eq!( v, expected, "version_string() must equal 'yame {{CARGO_PKG_VERSION}}'" );}
// ── typewriter mode tests ─────────────────────────────────────────────────
#[test]fn typewriter_mode_off_by_default() { let app = make_app(); assert!( !app.typewriter_mode, "typewriter_mode must default to false" );}
#[test]fn ctrl_t_toggles_typewriter_mode_on() { use super::input::{KeyOutcome, handle_key_event}; let mut app = make_app(); let outcome = handle_key_event(&mut app, ctrl(KeyCode::Char('t'))); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.typewriter_mode, "Ctrl+T must enable typewriter mode");}
#[test]fn ctrl_t_toggles_typewriter_mode_off() { use super::input::handle_key_event; let mut app = make_app(); app.typewriter_mode = true; handle_key_event(&mut app, ctrl(KeyCode::Char('t'))); assert!( !app.typewriter_mode, "second Ctrl+T must disable typewriter mode" );}
#[test]fn center_scroll_places_cursor_at_midpoint() { use super::commands::center_scroll; let mut app = make_app_with_lines(&["a"; 30]); app.textarea .move_cursor(tui_textarea::CursorMove::Jump(20, 0)); center_scroll(&mut app, make_editor_area(10)); // cursor_row=20, half=5 → scroll_top should be 15 assert_eq!( app.scroll_top, 15, "scroll_top must be cursor_row - viewport_height/2" );}
#[test]fn center_scroll_clamps_to_zero_near_top() { use super::commands::center_scroll; let mut app = make_app_with_lines(&["a"; 30]); app.textarea .move_cursor(tui_textarea::CursorMove::Jump(2, 0)); center_scroll(&mut app, make_editor_area(10)); // cursor_row=2, half=5 → saturating_sub → 0 assert_eq!(app.scroll_top, 0, "scroll_top must not underflow past 0");}
#[test]fn center_scroll_exact_midpoint_cursor() { use super::commands::center_scroll; let mut app = make_app_with_lines(&["a"; 20]); app.textarea .move_cursor(tui_textarea::CursorMove::Jump(5, 0)); center_scroll(&mut app, make_editor_area(10)); // cursor_row=5, half=5 → scroll_top=0 assert_eq!(app.scroll_top, 0);}
// ── go-to-line tests ──────────────────────────────────────────────────────
fn key(code: KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)}
fn ctrl(code: KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)}
#[test]fn goto_line_ctrl_g_enters_mode() { use super::input::{KeyOutcome, handle_key_event}; let mut app = make_app(); let outcome = handle_key_event(&mut app, ctrl(KeyCode::Char('g'))); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!(app.status.mode, StatusMode::GoToLine { .. }), "Ctrl+G must enter GoToLine mode" );}
#[test]fn goto_line_digits_appended_to_buffer() { let mut app = make_app(); app.status.start_goto_line(); handle_goto_line_key(&mut app, key(KeyCode::Char('4'))); handle_goto_line_key(&mut app, key(KeyCode::Char('2'))); assert_eq!(app.status.goto_input(), Some("42"));}
#[test]fn goto_line_non_digits_ignored() { let mut app = make_app(); app.status.start_goto_line(); handle_goto_line_key(&mut app, key(KeyCode::Char('a'))); handle_goto_line_key(&mut app, key(KeyCode::Char('!'))); assert_eq!( app.status.goto_input(), Some(""), "letters and symbols must be ignored" );}
#[test]fn goto_line_backspace_removes_last_digit() { let mut app = make_app(); app.status.start_goto_line(); handle_goto_line_key(&mut app, key(KeyCode::Char('1'))); handle_goto_line_key(&mut app, key(KeyCode::Char('2'))); handle_goto_line_key(&mut app, key(KeyCode::Backspace)); assert_eq!(app.status.goto_input(), Some("1"));}
#[test]fn goto_line_esc_cancels_without_jump() { let mut app = make_app(); app.status.start_goto_line(); handle_goto_line_key(&mut app, key(KeyCode::Char('1'))); let (row_before, _) = app.textarea.cursor(); handle_goto_line_key(&mut app, key(KeyCode::Esc)); let (row_after, _) = app.textarea.cursor(); assert!( matches!(app.status.mode, StatusMode::Normal), "Esc must return to Normal mode" ); assert_eq!(row_before, row_after, "Esc must not move the cursor");}
#[test]fn goto_line_enter_with_empty_input_is_noop() { let mut app = make_app(); app.status.start_goto_line(); let (row_before, _) = app.textarea.cursor(); handle_goto_line_key(&mut app, key(KeyCode::Enter)); let (row_after, _) = app.textarea.cursor(); assert!(matches!(app.status.mode, StatusMode::Normal)); assert_eq!( row_before, row_after, "empty input must not move the cursor" );}
#[test]fn goto_line_enter_jumps_to_correct_line() { let mut app = make_app_with_lines(&["line 1", "line 2", "line 3", "line 4", "line 5"]); app.status.start_goto_line(); handle_goto_line_key(&mut app, key(KeyCode::Char('3'))); handle_goto_line_key(&mut app, key(KeyCode::Enter)); let (row, col) = app.textarea.cursor(); assert_eq!(row, 2, "line 3 (1-indexed) == row 2 (0-indexed)"); assert_eq!(col, 0, "cursor must land at column 0"); assert!(matches!(app.status.mode, StatusMode::Normal));}
#[test]fn goto_line_enter_clamps_to_last_line() { let mut app = make_app_with_lines(&["only", "two"]); app.status.start_goto_line(); for c in "9999".chars() { handle_goto_line_key(&mut app, key(KeyCode::Char(c))); } handle_goto_line_key(&mut app, key(KeyCode::Enter)); let (row, _) = app.textarea.cursor(); assert_eq!( row, 1, "line number beyond doc length must clamp to last line" );}
#[test]fn goto_line_input_capped_at_seven_digits() { let mut app = make_app(); app.status.start_goto_line(); for c in "12345678".chars() { handle_goto_line_key(&mut app, key(KeyCode::Char(c))); } assert_eq!( app.status.goto_input().map(|s| s.len()), Some(7), "input buffer must be capped at 7 digits" );}
// ── handle_pair_wrap: one test per pair character ────────────────────────
fn select_all(app: &mut App, len: usize) { app.textarea.start_selection(); for _ in 0..len { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); }}
#[test]fn pair_wrap_paren_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "(hi)");}
#[test]fn pair_wrap_brace_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('{'))); assert_eq!(app.textarea.lines()[0], "{hi}");}
#[test]fn pair_wrap_double_quote_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('"'))); assert_eq!(app.textarea.lines()[0], "\"hi\"");}
#[test]fn pair_wrap_single_quote_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('\''))); assert_eq!(app.textarea.lines()[0], "'hi'");}
#[test]fn pair_wrap_backtick_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('`'))); assert_eq!(app.textarea.lines()[0], "`hi`");}
#[test]fn pair_wrap_underscore_wraps_selection() { let mut app = make_app_with_lines(&["hi"]); select_all(&mut app, 2); handle_pair_wrap(&mut app, make_key(KeyCode::Char('_'))); assert_eq!(app.textarea.lines()[0], "_hi_");}
// ── get_selection_text boundary tests ────────────────────────────────────
#[test]fn get_selection_multiline_start_col_respected() { // Selection begins mid-line. The col_start must be honoured so we only // get characters from col_start onward for the first row. let mut app = make_app_with_lines(&["abcde", "fghij"]); // Move cursor to col 2 on row 0, then start selection from there for _ in 0..2 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } app.textarea.start_selection(); // Extend to end of next line app.textarea.move_cursor(tui_textarea::CursorMove::Down); app.textarea.move_cursor(tui_textarea::CursorMove::End); let text = get_selection_text(&app).unwrap_or_default(); assert!( text.starts_with("cde"), "first-row selection must start at col_start, got: {:?}", text ); assert!( !text.starts_with("ab"), "chars before col_start must be excluded" );}
#[test]fn get_selection_multiline_end_col_respected() { // Selection ends mid-line. The col_end must be honoured so we only get // characters up to col_end for the last row. let mut app = make_app_with_lines(&["abc", "defgh"]); app.textarea.start_selection(); // Move to row 1, col 3 (selecting "abc\ndef") app.textarea.move_cursor(tui_textarea::CursorMove::Down); for _ in 0..3 { app.textarea.move_cursor(tui_textarea::CursorMove::Forward); } let text = get_selection_text(&app).unwrap_or_default(); assert!( text.ends_with("def"), "last-row selection must end at col_end, got: {:?}", text ); assert!( !text.contains('g'), "chars after col_end must be excluded, got: {:?}", text );}
#[test]fn get_selection_multiline_no_trailing_newline() { // The final row must NOT have a trailing '\n' even in a multiline selection. let mut app = make_app_with_lines(&["abc", "def"]); app.textarea.start_selection(); app.textarea.move_cursor(tui_textarea::CursorMove::Down); app.textarea.move_cursor(tui_textarea::CursorMove::End); let text = get_selection_text(&app).unwrap_or_default(); assert!( !text.ends_with('\n'), "selection must not have trailing newline, got: {:?}", text );}
// ── clamp_scroll boundary tests ──────────────────────────────────────────
#[test]fn clamp_scroll_cursor_at_exact_scroll_top_unchanged() { // cursor_row == scroll_top: cursor is exactly at the top of the viewport. // The `cursor_row < scroll_top` guard must NOT trigger here. let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 3; app.textarea .move_cursor(tui_textarea::CursorMove::Jump(3, 0)); clamp_scroll(&mut app, make_editor_area(10), 0); assert_eq!(app.scroll_top, 3, "cursor == scroll_top must not scroll up");}
#[test]fn clamp_scroll_cursor_subrow_on_wrapped_line() { // A long line (> column width) wraps into multiple visual rows. // With cursor at the last character, cursor_subrow must be computed // correctly so scroll_top advances by the right amount. // TEST_COL=82 → cw=80. A 100-char line wraps into 2 visual rows. let long = "x".repeat(100); let mut lines: Vec<&str> = vec![long.as_str()]; lines.extend(std::iter::repeat("a").take(10)); let mut app = make_app_with_lines(&lines); // Position cursor at char 99 (second wrapped sub-row of line 0). app.textarea .move_cursor(tui_textarea::CursorMove::Jump(0, 99)); // Viewport height=3, padding=1 → cursor_visual + 1 may exceed visible. clamp_scroll(&mut app, make_editor_area(3), 1); // scroll_top stays 0 because line 0 is the first line — just assert no panic // and that scroll_top hasn't gone negative. assert_eq!( app.scroll_top, 0, "wrapped cursor on line 0 must not push scroll_top below 0" );}
#[test]fn clamp_scroll_bottom_padding_exact_boundary() { // Cursor at exactly visible_rows - bottom_padding - 1 should NOT scroll. // Cursor at exactly visible_rows - bottom_padding should scroll. // height=10, padding=3: cursor at row 6 (0-indexed) → cursor_visual=6 < 10-3=7 → no scroll let mut app = make_app_with_lines(&["a"; 20]); app.scroll_top = 0; app.textarea .move_cursor(tui_textarea::CursorMove::Jump(6, 0)); clamp_scroll(&mut app, make_editor_area(10), 3); assert_eq!( app.scroll_top, 0, "cursor at visible_rows - padding - 1 must not scroll" );}
// ── handle_key_event tests ───────────────────────────────────────────────
use super::input::{KeyOutcome, handle_key_event};
#[test]fn handle_key_event_resets_free_scroll() { let mut app = make_app(); app.free_scroll = true; let k = make_key(KeyCode::Up); handle_key_event(&mut app, k); // Up key is a navigation key → goes through `_` arm → free_scroll cleared. // (Ctrl+Up would set it back to true, but plain Up does not.) assert!(!app.free_scroll, "any key press must clear free_scroll");}
#[test]fn handle_key_event_ctrl_s_returns_save() { let mut app = make_app(); let k = ctrl_key(KeyCode::Char('s')); assert_eq!(handle_key_event(&mut app, k), KeyOutcome::Save);}
#[test]fn handle_key_event_ctrl_q_clean_returns_exit() { let mut app = make_app(); app.is_dirty = false; let k = ctrl_key(KeyCode::Char('q')); assert_eq!(handle_key_event(&mut app, k), KeyOutcome::Exit);}
#[test]fn handle_key_event_ctrl_q_dirty_shows_prompt() { let mut app = make_app(); app.is_dirty = true; let k = ctrl_key(KeyCode::Char('q')); assert_eq!(handle_key_event(&mut app, k), KeyOutcome::Continue); assert!( matches!(app.status.mode, StatusMode::ExitPrompt), "dirty Ctrl+Q must raise ExitPrompt" );}
#[test]fn handle_key_event_ctrl_r_returns_reload_config() { let mut app = make_app(); let k = ctrl_key(KeyCode::Char('r')); assert_eq!(handle_key_event(&mut app, k), KeyOutcome::ReloadConfig);}
#[test]fn handle_key_event_ctrl_z_undoes_and_sets_force_redecorate() { let mut app = make_app_with_lines(&["hello"]); // Type a character to have something to undo. app.textarea.input(crossterm::event::KeyEvent::new( KeyCode::Char('x'), KeyModifiers::NONE, )); app.force_redecorate = false; let k = ctrl_key(KeyCode::Char('z')); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.force_redecorate, "Ctrl+Z must set force_redecorate"); assert!( app.last_keystroke.is_some(), "Ctrl+Z must set last_keystroke" );}
#[test]fn handle_key_event_cmd_z_undoes_and_sets_force_redecorate() { let mut app = make_app_with_lines(&["hello"]); app.textarea.input(crossterm::event::KeyEvent::new( KeyCode::Char('x'), KeyModifiers::NONE, )); app.force_redecorate = false; let k = crossterm::event::KeyEvent::new(KeyCode::Char('z'), KeyModifiers::SUPER); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.force_redecorate, "Cmd+Z must set force_redecorate"); assert!( app.last_keystroke.is_some(), "Cmd+Z must set last_keystroke" );}
#[test]fn handle_key_event_ctrl_y_redoes_and_sets_force_redecorate() { let mut app = make_app_with_lines(&["hello"]); app.force_redecorate = false; let k = ctrl_key(KeyCode::Char('y')); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.force_redecorate, "Ctrl+Y must set force_redecorate");}
#[test]fn handle_key_event_cmd_y_redoes_and_sets_force_redecorate() { let mut app = make_app_with_lines(&["hello"]); app.force_redecorate = false; let k = crossterm::event::KeyEvent::new(KeyCode::Char('y'), KeyModifiers::SUPER); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.force_redecorate, "Cmd+Y must set force_redecorate");}
#[test]fn handle_key_event_cmd_shift_z_redoes_and_sets_force_redecorate() { let mut app = make_app_with_lines(&["hello"]); app.force_redecorate = false; let k = crossterm::event::KeyEvent::new( KeyCode::Char('z'), KeyModifiers::SUPER | KeyModifiers::SHIFT, ); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert!( app.force_redecorate, "Cmd+Shift+Z must set force_redecorate" );}
// When Kitty's keyboard protocol is active (leaked from a previous app that did// not restore terminal state), the terminal emits Release events in addition to// Press events. A Release of 'z' after releasing Ctrl early — i.e. the key// arrives as (NONE, Char('z'), kind=Release) — must NOT insert a character.// The event_loop guards against this at dispatch time; this test confirms that// handle_key_event itself also does the right thing if it ever sees such an event// (it falls to the `_` arm → textarea.input → tui-textarea's own Release guard).#[test]fn release_event_for_bare_z_does_not_insert_character() { use crossterm::event::KeyEventKind; let mut app = make_app_with_lines(&["hello"]); let content_before: Vec<String> = app.textarea.lines().to_vec();
let k = crossterm::event::KeyEvent::new_with_kind( KeyCode::Char('z'), KeyModifiers::NONE, KeyEventKind::Release, ); let outcome = handle_key_event(&mut app, k); assert_eq!(outcome, KeyOutcome::Continue); assert_eq!( app.textarea.lines().to_vec(), content_before, "Release event for bare 'z' must not modify textarea content" );}
#[test]fn handle_key_event_ctrl_up_scrolls_up_and_sets_free_scroll() { let mut app = make_app_with_lines(&["a"; 10]); app.scroll_top = 5; let k = ctrl_key(KeyCode::Up); handle_key_event(&mut app, k); assert_eq!(app.scroll_top, 4, "Ctrl+Up must decrement scroll_top"); assert!(app.free_scroll, "Ctrl+Up must set free_scroll");}
#[test]fn handle_key_event_ctrl_up_saturates_at_zero() { let mut app = make_app_with_lines(&["a"; 5]); app.scroll_top = 0; handle_key_event(&mut app, ctrl_key(KeyCode::Up)); assert_eq!(app.scroll_top, 0, "Ctrl+Up at top must not underflow");}
#[test]fn handle_key_event_ctrl_down_scrolls_down_and_sets_free_scroll() { let mut app = make_app_with_lines(&["a"; 10]); app.scroll_top = 0; let k = ctrl_key(KeyCode::Down); handle_key_event(&mut app, k); assert_eq!(app.scroll_top, 1, "Ctrl+Down must increment scroll_top"); assert!(app.free_scroll, "Ctrl+Down must set free_scroll");}
#[test]fn handle_key_event_ctrl_down_saturates_at_last_line() { let mut app = make_app_with_lines(&["a"; 3]); app.scroll_top = 2; // already at max (len - 1 = 2) handle_key_event(&mut app, ctrl_key(KeyCode::Down)); assert_eq!( app.scroll_top, 2, "Ctrl+Down at bottom must not exceed last line" );}
#[test]fn handle_key_event_exit_prompt_y_returns_save_and_exit() { let mut app = make_app(); app.is_dirty = true; app.status.mode = StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, make_key(KeyCode::Char('Y'))); assert_eq!(outcome, KeyOutcome::SaveAndExit);}
#[test]fn handle_key_event_exit_prompt_n_returns_exit() { let mut app = make_app(); app.status.mode = StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, make_key(KeyCode::Char('n'))); assert_eq!(outcome, KeyOutcome::Exit);}
#[test]fn handle_key_event_exit_prompt_esc_cancels_to_normal() { let mut app = make_app(); app.status.mode = StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, make_key(KeyCode::Esc)); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!(app.status.mode, StatusMode::Normal), "Esc in exit prompt must restore Normal mode" );}
#[test]fn handle_key_event_exit_prompt_unknown_key_continues() { let mut app = make_app(); app.status.mode = StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, make_key(KeyCode::Char('z'))); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!(app.status.mode, StatusMode::ExitPrompt), "unknown key in exit prompt must not change mode" );}
// ── focus mode: paragraph-bounds logic ───────────────────────────────────────
#[test]fn focus_paragraph_bounds_no_blank_lines_spans_whole_doc() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()]; // Cursor anywhere in a single-paragraph doc → whole document range. assert_eq!(focus_paragraph_bounds(&lines, 1), (0, 2));}
#[test]fn focus_paragraph_bounds_cursor_on_blank_line_is_self() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["alpha".into(), "".into(), "gamma".into()]; // Blank line → single-line "paragraph". assert_eq!(focus_paragraph_bounds(&lines, 1), (1, 1));}
#[test]fn focus_paragraph_bounds_first_paragraph() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["hello".into(), "world".into(), "".into(), "other".into()]; // Cursor on line 0 → paragraph (0, 1). assert_eq!(focus_paragraph_bounds(&lines, 0), (0, 1));}
#[test]fn focus_paragraph_bounds_second_paragraph() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["hello".into(), "".into(), "world".into(), "foo".into()]; // Cursor on line 2 → paragraph (2, 3). assert_eq!(focus_paragraph_bounds(&lines, 2), (2, 3));}
#[test]fn focus_paragraph_bounds_middle_paragraph() { use yame::renderer::focus_paragraph_bounds; // "a\n\nb\nc\nd\n\ne" let lines: Vec<String> = vec![ "a".into(), "".into(), "b".into(), "c".into(), "d".into(), "".into(), "e".into(), ]; // Cursor at line 3 (inside "b c d" block) → (2, 4). assert_eq!(focus_paragraph_bounds(&lines, 3), (2, 4));}
#[test]fn focus_paragraph_bounds_empty_doc_returns_zero_zero() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec![]; assert_eq!(focus_paragraph_bounds(&lines, 0), (0, 0));}
#[test]fn focus_paragraph_bounds_cursor_oob_clamps_to_last_line() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["a".into(), "b".into()]; // cursor_row=99 → clamped to 1 → whole doc (0, 1). assert_eq!(focus_paragraph_bounds(&lines, 99), (0, 1));}
#[test]fn focus_paragraph_bounds_single_line_doc() { use yame::renderer::focus_paragraph_bounds; let lines: Vec<String> = vec!["only".into()]; assert_eq!(focus_paragraph_bounds(&lines, 0), (0, 0));}
// ── focus mode: toggle keybinding ────────────────────────────────────────────
#[test]fn focus_mode_off_by_default() { let app = make_app(); assert!(!app.focus_mode, "focus_mode must default to false");}
#[test]fn ctrl_d_toggles_focus_mode_on() { use super::input::{KeyOutcome, handle_key_event}; let mut app = make_app(); let outcome = handle_key_event(&mut app, ctrl(KeyCode::Char('d'))); assert_eq!(outcome, KeyOutcome::Continue); assert!(app.focus_mode, "Ctrl+D must enable focus mode");}
#[test]fn ctrl_d_toggles_focus_mode_off() { use super::input::handle_key_event; let mut app = make_app(); app.focus_mode = true; handle_key_event(&mut app, ctrl(KeyCode::Char('d'))); assert!(!app.focus_mode, "second Ctrl+D must disable focus mode");}