Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
690 changes: 685 additions & 5 deletions src/app.rs

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> =
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");
}
}
116 changes: 114 additions & 2 deletions src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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);
}
}
}
68 changes: 66 additions & 2 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,14 @@ fn shortcut_slot(virtual_key: u16) -> Option<u8> {

#[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 {
Expand All @@ -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);
Expand Down Expand 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);
}
}
Loading