diff --git a/src/app.rs b/src/app.rs index dfb9b02..4e8ab01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -802,8 +802,16 @@ impl App { fn render(&mut self, terminal: &Terminal) -> io::Result<()> { let (width, height) = terminal.size(); - let width = usize::from(width.max(42)); - let height = usize::from(height.max(12)); + let rows = self.render_rows(usize::from(width), usize::from(height)); + self.renderer.draw(rows) + } + + /// Builds the full frame for a terminal of `width` x `height` cells. + /// Pure string generation: no I/O, so tests can exercise every mode + /// without a real console. `render` is a thin wrapper around this. + fn render_rows(&mut self, width: usize, height: usize) -> Vec { + let width = width.max(42); + let height = height.max(12); let inner = width.saturating_sub(4); let list_height = height.saturating_sub(7); if self.selected < self.scroll { @@ -924,7 +932,7 @@ impl App { rows.push(format!("{FRAME}│{RESET} {} {FRAME}│{RESET}", fit(&prompt, inner))); let help = self.footer_line(); rows.push(format!("{FRAME}╰─{}─╯{RESET}", fit(&help, width.saturating_sub(4)))); - self.renderer.draw(rows) + rows } fn command_panel_layout(&mut self, inner: usize, list_height: usize) -> Option { @@ -1593,8 +1601,8 @@ mod tests { }; use super::{ - App, Mode, PanelLayout, ShellResult, TextField, agent_command, command_scroll, cursor_text, - editor_footer_for, footer_fits, footer_help, fuzzy_score, help_lines_for, + App, EditorField, Mode, PanelLayout, ShellResult, TextField, agent_command, command_scroll, + cursor_text, editor_footer_for, footer_fits, footer_help, fuzzy_score, help_lines_for, is_command_shortcut, manager_footer_for, panel_binding_row, panel_border, panel_border_with_binding, panel_field_dimensions, panel_field_row, panel_slot_row, panel_text_row, visible_width, @@ -2172,4 +2180,676 @@ mod tests { assert!(!footer.contains("Mayús+3")); fs::remove_dir_all(sandbox).expect("clean test sandbox"); } + + fn sandbox_app(label: &str) -> (App, std::path::PathBuf) { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let sandbox = std::env::temp_dir().join(format!("devnav-app-{label}-{unique}")); + let root = sandbox.join("home"); + fs::create_dir_all(&root).expect("create sandbox root"); + let app = + App::new(root, Config::default(), sandbox.join("config.tsv")).expect("create app"); + (app, sandbox) + } + + fn sandbox_app_with_dirs(label: &str, dirs: &[&str]) -> (App, std::path::PathBuf) { + let (app, sandbox) = sandbox_app(label); + for dir in dirs { + fs::create_dir_all(sandbox.join("home").join(dir)).expect("create child dir"); + } + let mut app = app; + app.refresh().expect("refresh after creating dirs"); + (app, sandbox) + } + + #[test] + fn ctrl_c_quits_from_any_mode() { + let (mut app, sandbox) = sandbox_app("ctrlc"); + assert_eq!(app.handle_key(Key::CtrlC).expect("quit"), Some(None)); + app.handle_key(Key::Char('/')).expect("open filter"); + assert_eq!(app.handle_key(Key::CtrlC).expect("quit from filter"), Some(None)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn normal_mode_q_and_escape_quit_without_a_shell_result() { + let (mut app, sandbox) = sandbox_app("quit"); + assert_eq!(app.handle_key(Key::Char('q')).expect("q"), Some(None)); + + let (mut app, sandbox2) = sandbox_app("quit-esc"); + assert_eq!(app.handle_key(Key::Escape).expect("esc"), Some(None)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn enter_and_dot_emit_change_directory_results() { + let (mut app, sandbox) = sandbox_app_with_dirs("enter", &["project"]); + let project = app.selected_entry().expect("entry").path.clone(); + match app.handle_key(Key::Enter).expect("enter") { + Some(Some(ShellResult::ChangeDirectory(path))) => assert_eq!(path, project), + other => panic!("expected ChangeDirectory, got {other:?}"), + } + + let (mut app, sandbox2) = sandbox_app("dot"); + let current = app.current.clone(); + match app.handle_key(Key::Char('.')).expect("dot") { + Some(Some(ShellResult::ChangeDirectory(path))) => assert_eq!(path, current), + other => panic!("expected ChangeDirectory for current, got {other:?}"), + } + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn enter_on_an_empty_list_is_a_no_op() { + let (mut app, sandbox) = sandbox_app("enter-empty"); + assert_eq!(app.handle_key(Key::Enter).expect("enter"), None); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn g_returns_to_the_startup_folder_and_u_refreshes_with_feedback() { + let (mut app, sandbox) = sandbox_app_with_dirs("home-refresh", &["sub"]); + let home = app.home.clone(); + app.handle_key(Key::Right).expect("enter subdir"); + assert_ne!(app.current, home); + app.handle_key(Key::Char('g')).expect("go home"); + assert_eq!(app.current, home); + + app.handle_key(Key::Char('u')).expect("refresh"); + assert_eq!(app.message, "Directorio actualizado"); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn shift_u_emits_an_update_shell_result() { + let (mut app, sandbox) = sandbox_app("update"); + match app.handle_key(Key::Char('U')).expect("update") { + Some(Some(ShellResult::Update)) => {} + other => panic!("expected Update, got {other:?}"), + } + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn ctrl_u_toggles_update_checks_and_persists_the_choice() { + let (mut app, sandbox) = sandbox_app("update-checks"); + let config_path = sandbox.join("config.tsv"); + app.handle_key(Key::CtrlU).expect("toggle off"); + assert_eq!(app.message, "Comprobación de actualizaciones al iniciar: desactivada"); + assert_eq!(Config::load(&config_path).expect("load").check_updates(), Some(false)); + app.handle_key(Key::CtrlU).expect("toggle on"); + assert_eq!(app.message, "Comprobación de actualizaciones al iniciar: activada"); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn selection_moves_and_clamps_at_both_ends() { + let (mut app, sandbox) = sandbox_app_with_dirs("move", &["a", "b", "c"]); + app.handle_key(Key::Up).expect("up at top"); + assert_eq!(app.selected, 0); + app.handle_key(Key::Down).expect("down"); + app.handle_key(Key::Char('j')).expect("j down"); + assert_eq!(app.selected, 2); + app.handle_key(Key::Down).expect("down at bottom"); + assert_eq!(app.selected, 2); + app.handle_key(Key::Char('k')).expect("k up"); + assert_eq!(app.selected, 1); + + // An empty list ignores movement entirely. + let (mut empty, sandbox2) = sandbox_app("move-empty"); + empty.handle_key(Key::Down).expect("down on empty"); + assert_eq!(empty.selected, 0); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn right_opens_the_highlighted_folder_and_left_returns_to_the_parent() { + let (mut app, sandbox) = sandbox_app_with_dirs("nav", &["sub"]); + let root = app.current.clone(); + app.handle_key(Key::Char('l')).expect("open with l"); + assert_eq!(app.current, root.join("sub")); + app.handle_key(Key::Char('h')).expect("parent with h"); + assert_eq!(app.current, root); + app.handle_key(Key::Right).expect("open with right"); + assert_eq!(app.current, root.join("sub")); + app.handle_key(Key::Backspace).expect("parent with backspace"); + assert_eq!(app.current, root); + app.handle_key(Key::Left).expect("parent with left"); + assert_eq!(app.current, root.parent().expect("parent").to_path_buf()); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn filter_mode_narrows_reorders_and_restores_the_list() { + let (mut app, sandbox) = sandbox_app_with_dirs("filter", &["alpha", "beta", "alpine"]); + app.handle_key(Key::Char('/')).expect("open filter"); + assert!(matches!(app.mode, Mode::Filter)); + for character in "al".chars() { + app.handle_key(Key::Char(character)).expect("type filter"); + } + assert_eq!(app.visible.len(), 2); + // Prefix matches outrank substring matches deeper in the name. + let labels: Vec = + app.visible.iter().map(|index| app.entries[*index].label()).collect(); + assert_eq!(labels, vec!["alpha", "alpine"]); + + app.handle_key(Key::Backspace).expect("backspace filter"); + assert_eq!(app.visible.len(), 3); + app.handle_key(Key::Char('l')).expect("retype"); + app.handle_key(Key::Down).expect("move in filter"); + app.handle_key(Key::Escape).expect("cancel filter"); + assert!(matches!(app.mode, Mode::Normal)); + assert!(app.input.is_empty()); + assert_eq!(app.visible.len(), 3); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn filter_enter_applies_the_query_and_keeps_the_mode_clean() { + let (mut app, sandbox) = sandbox_app_with_dirs("filter-enter", &["alpha", "beta"]); + app.handle_key(Key::Char('/')).expect("open filter"); + app.handle_key(Key::Char('a')).expect("type a"); + // "a" matches both "alpha" (prefix) and "beta" (substring). + assert_eq!(app.visible.len(), 2); + app.handle_key(Key::Enter).expect("apply filter"); + assert!(matches!(app.mode, Mode::Normal)); + assert_eq!(app.input, "a"); + assert_eq!(app.visible.len(), 2); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn path_mode_navigates_to_relative_and_absolute_paths() { + let (mut app, sandbox) = sandbox_app_with_dirs("path", &["sub"]); + let root = app.current.clone(); + app.handle_key(Key::Char('p')).expect("open path mode"); + assert!(matches!(app.mode, Mode::Path)); + for character in "sub".chars() { + app.handle_key(Key::Char(character)).expect("type path"); + } + app.handle_key(Key::Enter).expect("open relative path"); + assert_eq!(app.current, root.join("sub")); + + app.handle_key(Key::Char('p')).expect("open path mode again"); + let absolute = root.display().to_string(); + for character in absolute.chars() { + app.handle_key(Key::Char(character)).expect("type absolute path"); + } + app.handle_key(Key::Backspace).expect("fix a typo"); + app.handle_key(Key::Char(absolute.chars().last().expect("char"))).expect("retype"); + app.handle_key(Key::Enter).expect("open absolute path"); + assert_eq!(app.current, root); + + app.handle_key(Key::Char('p')).expect("open path mode third time"); + app.handle_key(Key::Char('x')).expect("type"); + app.handle_key(Key::Escape).expect("cancel"); + assert!(matches!(app.mode, Mode::Normal)); + assert!(app.input.is_empty()); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn path_mode_reports_an_invalid_path_in_both_locales() { + let (mut app, sandbox) = sandbox_app("path-invalid"); + app.handle_key(Key::Char('p')).expect("open path mode"); + for character in "no-existe".chars() { + app.handle_key(Key::Char(character)).expect("type"); + } + app.handle_key(Key::Enter).expect("submit invalid path"); + assert!(app.message.starts_with("La ruta no existe:")); + assert!(matches!(app.mode, Mode::Normal)); + + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let sandbox2 = std::env::temp_dir().join(format!("devnav-app-path-en-{unique}")); + fs::create_dir_all(sandbox2.join("home")).expect("create root"); + let mut config = Config::default(); + config.set_language("en-US"); + let mut app = App::new(sandbox2.join("home"), config, sandbox2.join("config.tsv")) + .expect("create app"); + app.handle_key(Key::Char('p')).expect("open path mode"); + app.handle_key(Key::Char('x')).expect("type"); + app.handle_key(Key::Enter).expect("submit invalid path"); + assert!(app.message.starts_with("Path does not exist:")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn alias_mode_saves_trims_and_clears_aliases() { + let (mut app, sandbox) = sandbox_app_with_dirs("alias", &["project"]); + let config_path = sandbox.join("config.tsv"); + let target = app.selected_entry().expect("entry").path.clone(); + + app.handle_key(Key::Char('a')).expect("open alias"); + assert!(matches!(app.mode, Mode::Alias { .. })); + for character in "principal".chars() { + app.handle_key(Key::Char(character)).expect("type alias"); + } + app.handle_key(Key::Enter).expect("save alias"); + assert_eq!(app.message, "Alias guardado"); + assert_eq!(Config::load(&config_path).expect("load").alias(&target), Some("principal")); + assert_eq!(app.selected_entry().expect("entry").label(), "principal - project"); + + // Submitting a blank alias removes it again. + app.handle_key(Key::Char('a')).expect("reopen alias"); + assert_eq!(app.input, "principal"); + for _ in 0..9 { + app.handle_key(Key::Backspace).expect("erase alias"); + } + app.handle_key(Key::Enter).expect("save blank alias"); + assert_eq!(Config::load(&config_path).expect("load").alias(&target), None); + + app.handle_key(Key::Char('a')).expect("open alias third time"); + app.handle_key(Key::Char('x')).expect("type"); + app.handle_key(Key::Escape).expect("cancel alias"); + assert!(matches!(app.mode, Mode::Normal)); + assert!(app.input.is_empty()); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn command_mode_executes_a_trimmed_command_and_ignores_empty_input() { + let (mut app, sandbox) = sandbox_app_with_dirs("command", &["project"]); + let target = app.selected_entry().expect("entry").path.clone(); + + app.handle_key(Key::Char('e')).expect("open command mode"); + assert!(matches!(app.mode, Mode::Command { .. })); + app.handle_key(Key::Enter).expect("empty command is ignored"); + assert!(matches!(app.mode, Mode::Command { .. })); + for character in " cargo test ".chars() { + app.handle_key(Key::Char(character)).expect("type command"); + } + match app.handle_key(Key::Enter).expect("run command") { + Some(Some(ShellResult::Execute { directory, command })) => { + assert_eq!(directory, target); + assert_eq!(command, "cargo test"); + } + other => panic!("expected Execute, got {other:?}"), + } + + let (mut app, sandbox2) = sandbox_app("command-escape"); + app.handle_key(Key::Char(':')).expect("open command mode with colon"); + app.handle_key(Key::Char('x')).expect("type"); + app.handle_key(Key::Backspace).expect("erase"); + assert!(app.input.is_empty()); + assert_eq!(app.handle_key(Key::Escape).expect("cancel"), None); + assert!(matches!(app.mode, Mode::Normal)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn command_mode_targets_the_current_folder_when_the_list_is_empty() { + let (mut app, sandbox) = sandbox_app("command-empty"); + app.handle_key(Key::Char('e')).expect("open command mode"); + assert!(matches!(&app.mode, Mode::Command { target } if target == &app.current.clone())); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn confirm_root_can_be_cancelled_with_escape_or_q() { + let (mut app, sandbox) = sandbox_app("root-cancel"); + app.handle_key(Key::CtrlS).expect("open confirmation"); + app.handle_key(Key::Escape).expect("cancel with escape"); + assert!(matches!(app.mode, Mode::Normal)); + assert_eq!(app.message, "Cambio de ruta cancelado"); + + app.handle_key(Key::CtrlS).expect("open confirmation again"); + app.handle_key(Key::Char('q')).expect("cancel with q"); + assert_eq!(app.message, "Cambio de ruta cancelado"); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn help_opens_scrolls_and_returns_to_the_previous_mode() { + let (mut app, sandbox) = sandbox_app("help"); + app.handle_key(Key::Char('/')).expect("open filter"); + app.handle_key(Key::F1).expect("open help over filter"); + assert!(matches!(app.mode, Mode::Help)); + app.handle_key(Key::Down).expect("scroll down"); + app.handle_key(Key::Char('j')).expect("scroll down with j"); + assert_eq!(app.help_scroll, 2); + app.handle_key(Key::Up).expect("scroll up"); + app.handle_key(Key::Char('x')).expect("unrelated key is ignored"); + assert_eq!(app.help_scroll, 1); + app.handle_key(Key::F1).expect("close help with F1"); + assert!(matches!(app.mode, Mode::Filter)); + + app.handle_key(Key::F1).expect("reopen help"); + app.handle_key(Key::Char('q')).expect("close help with q"); + assert!(matches!(app.mode, Mode::Filter)); + app.handle_key(Key::F1).expect("reopen help again"); + app.handle_key(Key::Enter).expect("close help with enter"); + assert!(matches!(app.mode, Mode::Filter)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn agent_keys_execute_against_the_highlighted_entry() { + let (mut app, sandbox) = sandbox_app_with_dirs("agent", &["project"]); + let target = app.selected_entry().expect("entry").path.clone(); + match app.handle_key(Key::Char('c')).expect("codex") { + Some(Some(ShellResult::Execute { directory, command })) => { + assert_eq!(directory, target); + assert_eq!(command, "codex"); + } + other => panic!("expected Execute, got {other:?}"), + } + + // Unknown characters and agent keys without a selection are no-ops. + let (mut empty, sandbox2) = sandbox_app("agent-empty"); + assert_eq!(empty.handle_key(Key::Char('z')).expect("unknown char"), None); + assert_eq!(empty.handle_key(Key::Char('c')).expect("agent without selection"), None); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn toggle_favorite_adds_and_removes_with_localized_feedback() { + let (mut app, sandbox) = sandbox_app_with_dirs("favorite", &["project"]); + let target = app.selected_entry().expect("entry").path.clone(); + app.handle_key(Key::Char('f')).expect("add favorite"); + assert_eq!(app.message, "Añadido a favoritos"); + assert!(app.entries.iter().any(|entry| entry.path == target && entry.favorite)); + app.handle_key(Key::Char('f')).expect("remove favorite"); + assert_eq!(app.message, "Eliminado de favoritos"); + assert!(app.entries.iter().all(|entry| !entry.favorite)); + + // With an empty list the toggle is a no-op. + let (mut empty, sandbox2) = sandbox_app("favorite-empty"); + empty.handle_key(Key::Char('f')).expect("favorite on empty list"); + assert!(empty.message.is_empty()); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn shift_f_toggles_global_favorites_visibility_with_feedback() { + let (mut app, sandbox) = sandbox_app("fav-visibility"); + let config_path = sandbox.join("config.tsv"); + app.handle_key(Key::Char('F')).expect("hide favorites"); + assert_eq!(app.message, "Favoritos globales ocultos"); + assert!(!Config::load(&config_path).expect("load").show_favorites()); + app.handle_key(Key::Char('F')).expect("show favorites"); + assert_eq!(app.message, "Favoritos globales visibles"); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn f3_is_reserved_while_the_editor_or_delete_confirmation_is_open() { + let (mut app, sandbox) = sandbox_app("f3-reserved"); + let mut config = Config::default(); + config.set_shortcut(1, Some("Dev".into()), "bun run dev".into()); + app.config = config; + app.handle_key(Key::F3).expect("open manager"); + app.handle_key(Key::Enter).expect("open editor"); + app.handle_key(Key::F3).expect("F3 ignored in editor"); + assert!(matches!(app.mode, Mode::CommandEditor { .. })); + app.handle_key(Key::Escape).expect("back to manager"); + app.handle_key(Key::Delete).expect("open delete confirmation"); + app.handle_key(Key::F3).expect("F3 ignored in confirmation"); + assert!(matches!(app.mode, Mode::ConfirmDelete { .. })); + app.handle_key(Key::Escape).expect("back to manager again"); + app.handle_key(Key::F3).expect("F3 closes the manager"); + assert!(matches!(app.mode, Mode::Normal)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn manager_navigation_clamps_and_delete_ignores_empty_slots() { + let (mut app, sandbox) = sandbox_app("manager-nav"); + app.handle_key(Key::F3).expect("open manager"); + app.handle_key(Key::Up).expect("up clamps at first slot"); + assert!(matches!(app.mode, Mode::Commands { selected: 0, .. })); + app.handle_key(Key::Down).expect("down"); + app.handle_key(Key::Down).expect("down again"); + assert!(matches!(app.mode, Mode::Commands { selected: 2, .. })); + for _ in 0..20 { + app.handle_key(Key::Down).expect("down"); + } + assert!(matches!(app.mode, Mode::Commands { selected: 8, .. })); + app.handle_key(Key::Delete).expect("delete on empty slot does nothing"); + assert!(matches!(app.mode, Mode::Commands { selected: 8, .. })); + app.handle_key(Key::Escape).expect("close manager"); + assert!(matches!(app.mode, Mode::Normal)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn editor_field_navigation_edits_both_fields() { + let (mut app, sandbox) = sandbox_app("editor-fields"); + app.handle_key(Key::F3).expect("open manager"); + app.handle_key(Key::Enter).expect("open editor"); + for character in "abc".chars() { + app.handle_key(Key::Char(character)).expect("type alias"); + } + app.handle_key(Key::Home).expect("home"); + app.handle_key(Key::Delete).expect("delete first char"); + app.handle_key(Key::End).expect("end"); + app.handle_key(Key::Left).expect("left"); + app.handle_key(Key::Right).expect("right"); + app.handle_key(Key::Backspace).expect("backspace"); + if let Mode::CommandEditor { alias, field, .. } = &app.mode { + assert_eq!(alias.value, "b"); + assert_eq!(*field, EditorField::Alias); + } else { + panic!("expected editor mode"); + } + app.handle_key(Key::Tab).expect("switch to command"); + app.handle_key(Key::Tab).expect("switch back to alias"); + if let Mode::CommandEditor { field, .. } = &app.mode { + assert_eq!(*field, EditorField::Alias); + } else { + panic!("expected editor mode"); + } + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn execute_shortcut_is_a_no_op_without_a_selection() { + let (mut app, sandbox) = sandbox_app("shortcut-no-selection"); + app.config.set_shortcut(1, None, "cargo test".into()); + assert_eq!(app.handle_key(Key::Shortcut(1)).expect("empty list"), None); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn text_field_cursor_operations_are_safe_at_the_edges() { + let mut field = TextField::new("ab".into()); + field.delete(); + assert_eq!(field.value, "ab"); + field.right(); + assert_eq!(field.cursor, 2); + field.backspace(); + assert_eq!(field.value, "a"); + field.home(); + field.backspace(); + assert_eq!(field.value, "a"); + field.left(); + assert_eq!(field.cursor, 0); + field.right(); + field.insert('ñ'); + // Deleting at the end of the buffer is a no-op, then the multibyte + // character is removed with a single delete after moving left. + field.delete(); + assert_eq!(field.value, "añ"); + field.left(); + field.delete(); + assert_eq!(field.value, "a"); + field.end(); + field.ensure_viewport(1); + assert!(field.viewport > 0); + field.home(); + field.ensure_viewport(4); + assert_eq!(field.viewport, 0); + } + + #[test] + fn normal_frame_renders_header_list_prompt_and_footer() { + let (mut app, sandbox) = sandbox_app_with_dirs("render-normal", &["project"]); + app.message = "hola".into(); + let rows = app.render_rows(80, 24); + let joined = rows.join("\n"); + assert!(joined.contains("DEV")); + assert!(joined.contains("project")); + assert!(joined.contains("hola")); + assert!(joined.contains("F1 Ayuda")); + + app.message.clear(); + let rows = app.render_rows(80, 24); + assert!(rows.join("\n").contains("1 carpetas")); + + // The frame honors the minimum terminal size. + let small = app.render_rows(10, 5); + assert!(small.join("\n").contains("DEV")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn help_panel_renders_in_both_locales() { + let (mut app, sandbox) = sandbox_app("render-help"); + app.handle_key(Key::F1).expect("open help"); + let rows = app.render_rows(100, 30); + let joined = rows.join("\n"); + assert!(joined.contains("ATAJOS DE TECLADO")); + assert!(joined.contains("Navegar por las carpetas")); + assert!(joined.contains("acciones disponibles")); + + let (mut app, sandbox2) = sandbox_app("render-help-en"); + app.config.set_language("en-US"); + app.locale = crate::i18n::Locale::EnUs; + app.handle_key(Key::F1).expect("open help"); + app.handle_key(Key::Down).expect("scroll"); + let rows = app.render_rows(100, 30); + let joined = rows.join("\n"); + assert!(joined.contains("KEYBOARD SHORTCUTS")); + assert!(joined.contains("actions available")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + fs::remove_dir_all(sandbox2).expect("clean sandbox2"); + } + + #[test] + fn command_panels_render_manager_editor_and_delete_confirmation() { + let (mut app, sandbox) = sandbox_app("render-panels"); + app.config.set_shortcut(1, Some("Dev".into()), "bun run dev".into()); + + app.handle_key(Key::F3).expect("open manager"); + let rows = app.render_rows(100, 30); + let joined = rows.join("\n"); + assert!(joined.contains("COMANDOS PERSONALIZADOS")); + assert!(joined.contains("bun run dev")); + assert!(joined.contains("Vacío")); + + app.handle_key(Key::Enter).expect("open editor"); + app.handle_key(Key::Char('X')).expect("type into alias"); + let rows = app.render_rows(100, 30); + let joined = rows.join("\n"); + assert!(joined.contains("EDITAR COMANDO")); + assert!(joined.contains("Alias (opcional)")); + assert!(joined.contains("X_")); + + app.handle_key(Key::Escape).expect("back to manager"); + app.handle_key(Key::Delete).expect("open delete confirmation"); + let rows = app.render_rows(100, 30); + let joined = rows.join("\n"); + assert!(joined.contains("ELIMINAR COMANDO")); + assert!(joined.contains("¿Eliminar este comando?")); + // Full-height delete panel renders the alias and command on their own rows. + assert!(joined.contains("Dev")); + assert!(joined.contains("bun run dev")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn delete_panel_uses_the_compact_layout_when_the_terminal_is_short() { + let (mut app, sandbox) = sandbox_app("render-delete-compact"); + app.config.set_shortcut(1, None, "cargo test".into()); + app.handle_key(Key::F3).expect("open manager"); + app.handle_key(Key::Delete).expect("open delete confirmation"); + // list_height 5 forces the compact 5-row delete panel. + let rows = app.render_rows(90, 12); + let joined = rows.join("\n"); + assert!(joined.contains("¿Eliminar este comando?")); + assert!(joined.contains("cargo test")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn prompts_reflect_the_active_mode() { + let (mut app, sandbox) = sandbox_app_with_dirs("render-prompts", &["project"]); + app.handle_key(Key::Char('/')).expect("filter"); + app.handle_key(Key::Char('a')).expect("type"); + assert!(app.render_rows(80, 24).join("\n").contains("/a")); + + app.handle_key(Key::Escape).expect("close filter"); + app.handle_key(Key::Char('p')).expect("path"); + app.handle_key(Key::Char('x')).expect("type"); + assert!(app.render_rows(80, 24).join("\n").contains("ruta › x_")); + + app.handle_key(Key::Escape).expect("close path"); + app.handle_key(Key::Char('a')).expect("alias"); + assert!(app.render_rows(80, 24).join("\n").contains("alias › _")); + + app.handle_key(Key::Escape).expect("close alias"); + app.handle_key(Key::Char('e')).expect("command"); + assert!(app.render_rows(80, 24).join("\n").contains("comando › _")); + + app.handle_key(Key::Escape).expect("close command"); + app.handle_key(Key::CtrlS).expect("confirm root"); + assert!(app.render_rows(80, 24).join("\n").contains("¿Guardar como inicio?")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn english_frame_uses_english_copy() { + let (mut app, sandbox) = sandbox_app("render-en"); + app.locale = crate::i18n::Locale::EnUs; + app.config.set_language("en-US"); + let joined = app.render_rows(80, 24).join("\n"); + assert!(joined.contains("0 folders")); + assert!(joined.contains("F1 Help F2 Language")); + assert!(joined.contains("FAVORITES VISIBLE")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn selection_marker_and_favorite_star_are_rendered() { + let (mut app, sandbox) = sandbox_app_with_dirs("render-markers", &["alpha", "beta"]); + let target = app.selected_entry().expect("entry").path.clone(); + app.handle_key(Key::Char('f')).expect("favorite"); + let joined = app.render_rows(80, 24).join("\n"); + assert!(joined.contains("★")); + assert!(joined.contains('›')); + assert!(app.entries.iter().any(|entry| entry.path == target && entry.favorite)); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn render_scrolls_to_keep_the_selection_visible() { + let dirs: Vec = (0..30).map(|index| format!("dir{index:02}")).collect(); + let (mut app, sandbox) = sandbox_app("render-scroll"); + for dir in &dirs { + fs::create_dir_all(sandbox.join("home").join(dir)).expect("create child"); + } + app.refresh().expect("refresh"); + for _ in 0..25 { + app.handle_key(Key::Down).expect("move down"); + } + let rows = app.render_rows(80, 12); + assert!(rows.join("\n").contains("dir25")); + assert!(app.scroll > 0); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn help_layout_renders_blank_rows_outside_the_panel() { + let (mut app, sandbox) = sandbox_app("render-help-viewport"); + app.handle_key(Key::F1).expect("open help"); + let rows = app.render_rows(60, 40); + // Header + subheader + separator + list + separator + prompt + footer. + assert_eq!(rows.len(), 40 - 7 + 6); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } } diff --git a/src/config.rs b/src/config.rs index 42ad9f1..0c23329 100644 --- a/src/config.rs +++ b/src/config.rs @@ -567,4 +567,142 @@ mod tests { assert_eq!(slot.command, command); fs::remove_file(config_path).expect("remove config"); } + + #[test] + fn default_path_points_inside_localappdata() { + let path = Config::default_path().expect("LOCALAPPDATA is set on Windows"); + assert!(path.ends_with(std::path::Path::new("DevNav").join("config.tsv"))); + } + + #[test] + fn load_returns_defaults_when_the_file_is_missing() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let missing = std::env::temp_dir().join(format!("devnav-missing-{unique}")).join("c.tsv"); + let config = Config::load(&missing).expect("missing file yields defaults"); + assert!(config.root().is_none()); + assert!(config.show_favorites()); + assert_eq!(config.language(), None); + assert!(config.configured_shortcuts().is_empty()); + } + + #[test] + fn load_propagates_real_io_errors_instead_of_swallowing_them() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let directory = std::env::temp_dir().join(format!("devnav-load-dir-{unique}")); + fs::create_dir_all(&directory).expect("create directory"); + assert!(Config::load(&directory).is_err()); + fs::remove_dir_all(directory).expect("clean directory"); + } + + #[test] + fn parse_tsv_reads_every_supported_record_type() { + let config = Config::parse_tsv( + "root\tC:\\code\nshow_favorites\tfalse\ncheck_updates\ttrue\nlanguage\ten-US\n\ + favorite\tC:\\code\\dev-nav\nalias\tC:\\code\\dev-nav\tprincipal\n\ + shortcut\t3\tDev\tbun run dev\nunknown\trecord\n\n", + ); + assert_eq!(config.root(), Some(std::path::Path::new("C:\\code"))); + assert!(!config.show_favorites()); + assert_eq!(config.check_updates(), Some(true)); + assert_eq!(config.language(), Some("en-US")); + assert!(config.is_favorite(std::path::Path::new("C:\\code\\dev-nav"))); + assert_eq!(config.alias(std::path::Path::new("C:\\code\\dev-nav")), Some("principal")); + assert_eq!(config.shortcut(3).map(|s| s.command.as_str()), Some("bun run dev")); + } + + #[test] + fn parse_tsv_rejects_invalid_languages_and_shortcut_records() { + let config = Config::parse_tsv( + "language\tfr-FR\ncheck_updates\tbogus\nshortcut\t10\ta\tcmd\n\ + shortcut\tx\ta\tcmd\nshortcut\t2\talias-only-no-tab\nshortcut\t4\t\t \n", + ); + assert_eq!(config.language(), None); + assert_eq!(config.check_updates(), Some(false)); + assert!(config.shortcut(10).is_none()); + assert!(config.shortcut(2).is_none()); + assert!(config.shortcut(4).is_none()); + } + + #[test] + fn toggle_favorite_adds_then_removes_the_path() { + let mut config = Config::default(); + let path = std::path::Path::new("C:\\code\\dev-nav"); + assert!(config.toggle_favorite(path)); + assert!(config.is_favorite(path)); + assert!(config.favorite_paths().any(|favorite| favorite == path)); + assert!(!config.toggle_favorite(path)); + assert!(!config.is_favorite(path)); + } + + #[test] + fn set_alias_trims_and_removes_on_blank_values() { + let mut config = Config::default(); + let path = std::path::PathBuf::from("C:\\code\\dev-nav"); + config.set_alias(path.clone(), " principal ".into()); + assert_eq!(config.alias(&path), Some("principal")); + config.set_alias(path.clone(), " ".into()); + assert_eq!(config.alias(&path), None); + config.set_alias(path.clone(), String::new()); + assert_eq!(config.alias(&path), None); + } + + #[test] + fn set_language_accepts_only_supported_locales() { + let mut config = Config::default(); + config.set_language("en-US"); + assert_eq!(config.language(), Some("en-US")); + config.set_language("de-DE"); + assert_eq!(config.language(), Some("en-US")); + } + + #[test] + fn toggle_update_checks_flips_the_stored_value() { + let mut config = Config::default(); + assert!(!config.toggle_update_checks()); + assert_eq!(config.check_updates(), Some(false)); + assert!(config.toggle_update_checks()); + assert_eq!(config.check_updates(), Some(true)); + } + + #[test] + fn configured_shortcuts_are_ordered_by_slot_index() { + let mut config = Config::default(); + config.set_shortcut(7, None, "seven".into()); + config.set_shortcut(2, None, "two".into()); + config.set_shortcut(5, None, "five".into()); + let slots: Vec = + config.configured_shortcuts().iter().map(|(index, _)| *index).collect(); + assert_eq!(slots, vec![2, 5, 7]); + } + + #[test] + fn save_creates_missing_parent_directories_and_round_trips() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let sandbox = std::env::temp_dir().join(format!("devnav-nested-{unique}")); + let config_path = sandbox.join("deep").join("deeper").join("config.tsv"); + let mut config = Config::default(); + config.set_root(std::path::PathBuf::from("C:\\code")); + config.set_alias(std::path::PathBuf::from("C:\\code\\tab\tsheet"), "con\ttab".into()); + config.save(&config_path).expect("save into nested directory"); + + let loaded = Config::load(&config_path).expect("load"); + assert_eq!(loaded.root(), Some(std::path::Path::new("C:\\code"))); + assert_eq!(loaded.alias(std::path::Path::new("C:\\code\\tab\tsheet")), Some("con\ttab")); + fs::remove_dir_all(sandbox).expect("clean sandbox"); + } + + #[test] + fn save_replaces_an_existing_config_atomically() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let config_path = std::env::temp_dir().join(format!("devnav-replace-{unique}.tsv")); + let mut config = Config::default(); + config.save(&config_path).expect("initial save"); + config.set_shortcut(1, Some("Dev".into()), "bun run dev".into()); + config.save(&config_path).expect("replace existing file"); + assert_eq!( + Config::load(&config_path).expect("load").shortcut(1).map(|s| s.command.as_str()), + Some("bun run dev") + ); + fs::remove_file(config_path).expect("remove config"); + } } diff --git a/src/i18n.rs b/src/i18n.rs index 9c4d726..0496cb2 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -270,8 +270,9 @@ pub fn official_bindings() -> &'static [KeyBinding] { #[cfg(test)] mod tests { use super::{ - KeyBinding, KeyToken, Locale, Modifier, format_binding, official_bindings, - resolve_preferred_tags, + KeyBinding, KeyToken, Locale, Modifier, TextId, delete_footer, editor_footer, + editor_footer_compact, editor_title, format_binding, manager_footer, + manager_footer_compact, official_bindings, resolve_preferred_tags, shift_range, text, }; #[test] @@ -305,4 +306,115 @@ mod tests { assert_eq!(resolve_preferred_tags(["de-DE", "es-ES", "en-US"]), Locale::EsEs); assert_eq!(resolve_preferred_tags(["de-DE", "fr-FR"]), Locale::EnUs); } + + #[test] + fn locale_tags_and_alternation_are_symmetric() { + assert_eq!(Locale::EsEs.tag(), "es-ES"); + assert_eq!(Locale::EnUs.tag(), "en-US"); + assert_eq!(Locale::EsEs.other(), Locale::EnUs); + assert_eq!(Locale::EnUs.other(), Locale::EsEs); + assert_eq!(Locale::EsEs.other().other(), Locale::EsEs); + } + + #[test] + fn every_defined_text_id_is_translated_in_both_locales() { + let cases: [(TextId, &str, &str); 14] = [ + (TextId::ManagerTitle, "COMANDOS PERSONALIZADOS", "CUSTOM COMMANDS"), + (TextId::DeleteTitle, "ELIMINAR COMANDO", "REMOVE COMMAND"), + (TextId::Empty, "Vacío", "Empty"), + (TextId::AliasOptional, "Alias (opcional)", "Alias (optional)"), + (TextId::Command, "Comando", "Command"), + (TextId::Save, "Guardar", "Save"), + (TextId::Cancel, "Cancelar", "Cancel"), + (TextId::Delete, "Eliminar", "Delete"), + (TextId::ConfirmDelete, "¿Eliminar comando?", "Remove command?"), + (TextId::SaveError, "No se pudo guardar", "Could not save"), + (TextId::DeleteError, "No se pudo eliminar", "Could not delete"), + (TextId::CommandSaved, "Comando guardado", "Command saved"), + (TextId::CommandDeleted, "Comando eliminado", "Command deleted"), + (TextId::ManageCommands, "Gestionar comandos personalizados", "Manage custom commands"), + ]; + for (id, expected_es, expected_en) in cases { + assert_eq!(text(Locale::EsEs, id), expected_es, "es-ES {id:?}"); + assert_eq!(text(Locale::EnUs, id), expected_en, "en-US {id:?}"); + } + // Identifiers without UI copy resolve to an empty string. + assert_eq!(text(Locale::EsEs, TextId::Help), ""); + assert_eq!(text(Locale::EnUs, TextId::Quit), ""); + } + + #[test] + fn shift_range_and_panel_copy_are_localized() { + assert_eq!(shift_range(Locale::EsEs), "Mayús+1–9"); + assert_eq!(shift_range(Locale::EnUs), "Shift+1–9"); + assert!(manager_footer(Locale::EsEs).contains("Supr")); + assert!(manager_footer(Locale::EnUs).contains("Delete")); + assert!(manager_footer_compact(Locale::EsEs).contains("Supr")); + assert!(manager_footer_compact(Locale::EnUs).contains("Del")); + assert!(editor_footer(Locale::EsEs).contains("Tab")); + assert!(editor_footer(Locale::EnUs).contains("Tab")); + assert_eq!(editor_footer_compact(Locale::EsEs), "Tab · Enter · Esc"); + assert_eq!(editor_footer_compact(Locale::EnUs), "Tab · Enter · Esc"); + assert!(delete_footer(Locale::EsEs).contains("Confirmar")); + assert!(delete_footer(Locale::EnUs).contains("Confirm")); + } + + #[test] + fn editor_title_distinguishes_new_from_edit_in_both_locales() { + assert_eq!(editor_title(Locale::EsEs, true), "NUEVO COMANDO"); + assert_eq!(editor_title(Locale::EsEs, false), "EDITAR COMANDO"); + assert_eq!(editor_title(Locale::EnUs, true), "NEW COMMAND"); + assert_eq!(editor_title(Locale::EnUs, false), "EDIT COMMAND"); + } + + #[test] + fn format_binding_covers_modifiers_and_special_keys() { + let cases: [(KeyBinding, Locale, &str); 18] = [ + ( + KeyBinding::with_modifier(Modifier::Ctrl, KeyToken::Char('S')), + Locale::EsEs, + "Ctrl+S", + ), + ( + KeyBinding::with_modifier(Modifier::Ctrl, KeyToken::Char('U')), + Locale::EnUs, + "Ctrl+U", + ), + ( + KeyBinding::with_modifier(Modifier::Shift, KeyToken::Char('F')), + Locale::EsEs, + "Mayús+F", + ), + ( + KeyBinding::with_modifier(Modifier::Shift, KeyToken::Char('F')), + Locale::EnUs, + "Shift+F", + ), + (KeyBinding::plain(KeyToken::Char('q')), Locale::EsEs, "q"), + (KeyBinding::plain(KeyToken::F1), Locale::EsEs, "F1"), + (KeyBinding::plain(KeyToken::F2), Locale::EnUs, "F2"), + (KeyBinding::plain(KeyToken::F3), Locale::EsEs, "F3"), + (KeyBinding::plain(KeyToken::Enter), Locale::EnUs, "Enter"), + (KeyBinding::plain(KeyToken::Escape), Locale::EsEs, "Esc"), + (KeyBinding::plain(KeyToken::Up), Locale::EsEs, "↑"), + (KeyBinding::plain(KeyToken::Down), Locale::EnUs, "↓"), + (KeyBinding::plain(KeyToken::Left), Locale::EsEs, "←"), + (KeyBinding::plain(KeyToken::Right), Locale::EnUs, "→"), + (KeyBinding::plain(KeyToken::Backspace), Locale::EsEs, "Retroceso"), + (KeyBinding::plain(KeyToken::Backspace), Locale::EnUs, "Backspace"), + ( + KeyBinding::with_modifier(Modifier::Shift, KeyToken::Enter), + Locale::EnUs, + "Shift+Enter", + ), + ( + KeyBinding::with_modifier(Modifier::Ctrl, KeyToken::Backspace), + Locale::EsEs, + "Ctrl+Retroceso", + ), + ]; + for (binding, locale, expected) in cases { + assert_eq!(format_binding(binding, locale), expected); + } + } } diff --git a/src/input.rs b/src/input.rs index 404192f..37dd513 100644 --- a/src/input.rs +++ b/src/input.rs @@ -139,9 +139,14 @@ fn shortcut_slot(virtual_key: u16) -> Option { #[cfg(test)] mod tests { - use super::{Key, VK_DOWN, VK_ESCAPE, VK_F1, VK_F2, VK_F3, VK_UP, map_key}; + use super::{ + Key, VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F2, VK_F3, VK_HOME, VK_LEFT, + VK_RETURN, VK_RIGHT, VK_TAB, VK_UP, map_key, shortcut_slot, + }; use windows_sys::Win32::System::Console::KEY_EVENT_RECORD; - use windows_sys::Win32::System::Console::{LEFT_CTRL_PRESSED, SHIFT_PRESSED}; + use windows_sys::Win32::System::Console::{ + LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, RIGHT_ALT_PRESSED, SHIFT_PRESSED, + }; fn key_event(virtual_key: u16) -> KEY_EVENT_RECORD { KEY_EVENT_RECORD { @@ -152,6 +157,12 @@ mod tests { } } + fn key_event_with_char(virtual_key: u16, character: char) -> KEY_EVENT_RECORD { + let mut event = key_event(virtual_key); + event.uChar.UnicodeChar = character as u16; + event + } + #[test] fn navigation_keys_are_not_escape() { assert_eq!(map_key(key_event(VK_UP)), Key::Up); @@ -221,4 +232,57 @@ mod tests { shifted.dwControlKeyState = SHIFT_PRESSED; assert_eq!(map_key(shifted), Key::Shortcut(1)); } + + #[test] + fn editing_and_function_keys_map_to_dedicated_variants() { + assert_eq!(map_key(key_event(VK_LEFT)), Key::Left); + assert_eq!(map_key(key_event(VK_RIGHT)), Key::Right); + assert_eq!(map_key(key_event(VK_RETURN)), Key::Enter); + assert_eq!(map_key(key_event(VK_BACK)), Key::Backspace); + assert_eq!(map_key(key_event(VK_DELETE)), Key::Delete); + assert_eq!(map_key(key_event(VK_HOME)), Key::Home); + assert_eq!(map_key(key_event(VK_END)), Key::End); + assert_eq!(map_key(key_event(VK_TAB)), Key::Tab); + } + + #[test] + fn control_c_maps_to_a_dedicated_quit_signal() { + let mut event = key_event(u16::from(b'C')); + event.dwControlKeyState = LEFT_CTRL_PRESSED; + assert_eq!(map_key(event), Key::CtrlC); + } + + #[test] + fn printable_characters_map_through_including_unicode() { + assert_eq!(map_key(key_event_with_char(u16::from(b'A'), 'a')), Key::Char('a')); + assert_eq!(map_key(key_event_with_char(0x31, 'ñ')), Key::Char('ñ')); + assert_eq!(map_key(key_event_with_char(0x4E, '界')), Key::Char('界')); + } + + #[test] + fn control_characters_without_a_virtual_key_are_unknown() { + assert_eq!(map_key(key_event_with_char(0x41, '\u{1}')), Key::Unknown); + assert_eq!(map_key(key_event_with_char(0x41, '\u{7f}')), Key::Unknown); + } + + #[test] + fn alt_shift_digit_stays_available_for_the_os() { + let mut event = key_event_with_char(u16::from(b'1'), '!'); + event.dwControlKeyState = SHIFT_PRESSED | LEFT_ALT_PRESSED; + assert_ne!(map_key(event), Key::Shortcut(1)); + + let mut event = key_event_with_char(u16::from(b'2'), '@'); + event.dwControlKeyState = SHIFT_PRESSED | RIGHT_ALT_PRESSED; + assert_ne!(map_key(event), Key::Shortcut(2)); + } + + #[test] + fn shortcut_slot_only_accepts_top_row_digits_one_to_nine() { + assert_eq!(shortcut_slot(0x30), None); + assert_eq!(shortcut_slot(0x31), Some(1)); + assert_eq!(shortcut_slot(0x39), Some(9)); + assert_eq!(shortcut_slot(0x3A), None); + assert_eq!(shortcut_slot(0x2F), None); + assert_eq!(shortcut_slot(0x61), None); + } } diff --git a/src/main.rs b/src/main.rs index f4b6ae1..9a6e69a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -178,3 +178,105 @@ fn parse_shortcut_index(raw: Option<&str>) -> io::Result { #[allow(dead_code)] fn _assert_shell_result_is_used(_: ShellResult) {} + +#[cfg(test)] +mod tests { + use std::{ + fs, + time::{SystemTime, UNIX_EPOCH}, + }; + + use super::{Config, argument_value, parse_shortcut_index, try_config_command}; + + fn temp_config_path(label: &str) -> std::path::PathBuf { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + std::env::temp_dir().join(format!("devnav-main-{label}-{unique}.tsv")) + } + + #[test] + fn argument_value_returns_the_value_after_the_flag() { + let args = vec!["dev".to_string(), "--result".to_string(), "out.bin".to_string()]; + assert_eq!(argument_value(&args, "--result"), Some("out.bin".to_string())); + assert_eq!(argument_value(&args, "--root"), None); + assert_eq!(argument_value(&[], "--result"), None); + let trailing = vec!["--result".to_string()]; + assert_eq!(argument_value(&trailing, "--result"), None); + } + + #[test] + fn parse_shortcut_index_accepts_only_one_to_nine() { + assert_eq!(parse_shortcut_index(Some("1")).expect("one"), 1); + assert_eq!(parse_shortcut_index(Some("9")).expect("nine"), 9); + assert!(parse_shortcut_index(Some("0")).is_err()); + assert!(parse_shortcut_index(Some("10")).is_err()); + assert!(parse_shortcut_index(Some("x")).is_err()); + assert!(parse_shortcut_index(None).is_err()); + } + + #[test] + fn non_config_arguments_are_not_config_commands() { + let config_path = temp_config_path("none"); + assert!(!try_config_command(&[], &config_path).expect("no args")); + assert!( + !try_config_command(&["--root".to_string()], &config_path).expect("unknown command") + ); + } + + #[test] + fn set_language_persists_the_resolved_locale() { + let config_path = temp_config_path("language"); + let args = vec!["--set-language".to_string(), "es-MX".to_string()]; + assert!(try_config_command(&args, &config_path).expect("set language")); + assert_eq!(Config::load(&config_path).expect("load").language(), Some("es-ES")); + fs::remove_file(config_path).expect("remove config"); + } + + #[test] + fn set_language_rejects_unsupported_locales() { + let config_path = temp_config_path("bad-language"); + let args = vec!["--set-language".to_string(), "de-DE".to_string()]; + assert!(try_config_command(&args, &config_path).is_err()); + assert!(!config_path.exists()); + } + + #[test] + fn set_shortcut_parses_command_and_optional_alias() { + let config_path = temp_config_path("shortcut"); + let args = vec![ + "--set-shortcut".to_string(), + "2".to_string(), + "cargo".to_string(), + "test".to_string(), + "--alias".to_string(), + "Tests".to_string(), + ]; + assert!(try_config_command(&args, &config_path).expect("set shortcut")); + let loaded = Config::load(&config_path).expect("load"); + let slot = loaded.shortcut(2).expect("slot"); + // Only the first positional token becomes the command; the alias flag + // is recognized after it. + assert_eq!(slot.command, "cargo"); + assert_eq!(slot.alias.as_deref(), Some("Tests")); + fs::remove_file(config_path).expect("remove config"); + } + + #[test] + fn set_shortcut_requires_a_command() { + let config_path = temp_config_path("no-command"); + let args = vec!["--set-shortcut".to_string(), "2".to_string()]; + assert!(try_config_command(&args, &config_path).is_err()); + } + + #[test] + fn clear_shortcut_removes_the_binding() { + let config_path = temp_config_path("clear"); + let mut config = Config::default(); + config.set_shortcut(4, Some("Build".into()), "bun run build".into()); + config.save(&config_path).expect("save shortcut"); + + let args = vec!["--clear-shortcut".to_string(), "4".to_string()]; + assert!(try_config_command(&args, &config_path).expect("clear shortcut")); + assert!(Config::load(&config_path).expect("load").shortcut(4).is_none()); + fs::remove_file(config_path).expect("remove config"); + } +} diff --git a/src/model.rs b/src/model.rs index eae6adb..3be7802 100644 --- a/src/model.rs +++ b/src/model.rs @@ -46,7 +46,40 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use super::ShellResult; + use super::{DirectoryEntry, ShellResult}; + + #[test] + fn label_prefers_a_non_empty_alias_over_the_name() { + let mut entry = DirectoryEntry { + path: std::path::PathBuf::from("C:\\code\\dev-nav"), + name: "dev-nav".into(), + alias: Some("principal".into()), + favorite: false, + }; + assert_eq!(entry.label(), "principal - dev-nav"); + + entry.alias = Some(" ".into()); + assert_eq!(entry.label(), " - dev-nav"); + + entry.alias = Some(String::new()); + assert_eq!(entry.label(), "dev-nav"); + + entry.alias = None; + assert_eq!(entry.label(), "dev-nav"); + } + + #[test] + fn change_directory_result_carries_the_path_verbatim() { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).expect("system time").as_nanos(); + let path = std::env::temp_dir().join(format!("devnav-cd-{unique}")); + let directory = std::path::PathBuf::from("C:\\code\\dev-nav"); + + ShellResult::ChangeDirectory(directory.clone()).write_to(&path).expect("write cd result"); + + let payload = fs::read_to_string(&path).expect("read cd result"); + assert_eq!(payload, format!("cd\0{}\0", directory.display())); + fs::remove_file(path).expect("remove result"); + } #[test] fn update_result_uses_a_dedicated_shell_message() { diff --git a/src/render.rs b/src/render.rs index bdf13a0..40a7f58 100644 --- a/src/render.rs +++ b/src/render.rs @@ -52,3 +52,43 @@ pub fn fit(text: &str, width: usize) -> String { text.chars().take(width).collect() } } + +#[cfg(test)] +mod tests { + use super::{SELECTED_BG, SELECTED_FG, fit, selected}; + + #[test] + fn fit_pads_short_text_to_the_requested_width() { + assert_eq!(fit("dev", 6), "dev "); + assert_eq!(fit("", 3), " "); + assert_eq!(fit("exact", 5), "exact"); + } + + #[test] + fn fit_truncates_long_text_with_an_ellipsis() { + assert_eq!(fit("abcdef", 4), "abc…"); + assert_eq!(fit("carpeta", 2), "c…"); + } + + #[test] + fn fit_handles_degenerate_widths() { + assert_eq!(fit("abc", 1), "a"); + assert_eq!(fit("abc", 0), ""); + assert_eq!(fit("", 0), ""); + } + + #[test] + fn fit_counts_unicode_scalar_values_not_bytes() { + assert_eq!(fit("áé", 4), "áé "); + assert_eq!(fit("áéí", 2), "á…"); + assert_eq!(fit("日本語", 2), "日…"); + } + + #[test] + fn selected_wraps_the_fitted_text_in_highlight_colors() { + let row = selected("entry", 8); + assert!(row.starts_with(SELECTED_BG)); + assert!(row.contains(SELECTED_FG)); + assert!(row.contains("entry ")); + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 95033e4..84fe536 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -90,3 +90,26 @@ fn validate_handle(handle: HANDLE) -> io::Result<()> { fn win32(success: i32) -> io::Result<()> { if success == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::{validate_handle, win32}; + use windows_sys::Win32::Foundation::{HANDLE, INVALID_HANDLE_VALUE}; + + #[test] + fn validate_handle_rejects_null_and_invalid_handles() { + assert!(validate_handle(std::ptr::null_mut::() as HANDLE).is_err()); + assert!(validate_handle(INVALID_HANDLE_VALUE).is_err()); + } + + #[test] + fn validate_handle_accepts_any_other_handle_value() { + assert!(validate_handle(1 as HANDLE).is_ok()); + } + + #[test] + fn win32_maps_zero_to_error_and_nonzero_to_ok() { + assert!(win32(0).is_err()); + assert!(win32(1).is_ok()); + } +}