diff --git a/Cargo.lock b/Cargo.lock index 2c82fb6..171a49a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "image", "serde", "serde_json", + "tempfile", ] [[package]] @@ -113,7 +114,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -124,7 +125,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -753,6 +754,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fax" version = "0.2.6" @@ -2185,6 +2192,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index daac479..dc89d50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ serde_json = { version = "1.0.149", default-features = false, features = [ ] } clap = { version = "4.5", features = ["derive"] } +[dev-dependencies] +tempfile = "3.15" + [profile.release] opt-level = 3 lto = true diff --git a/src/tests/config_roundtrip.rs b/src/tests/config_roundtrip.rs new file mode 100644 index 0000000..6bb2950 --- /dev/null +++ b/src/tests/config_roundtrip.rs @@ -0,0 +1,116 @@ +#[cfg(test)] +mod config_roundtrip_tests { + use crate::config::user::UserConfig; + use crate::hyprland::SearchOptions; + use crate::ui::types::{ColumnVisibility, Theme}; + use std::fs; + use tempfile::TempDir; + + fn setup_temp_config(temp_dir: &TempDir) -> std::path::PathBuf { + let config_path = temp_dir.path().join("hyprbind_test.json"); + config_path + } + + /// Ensures default config can be serialized and deserialized without data loss + #[test] + fn test_default_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config_path = setup_temp_config(&temp_dir); + + let original = UserConfig::default(); + let json = serde_json::to_string_pretty(&original).unwrap(); + fs::write(&config_path, json).unwrap(); + + let loaded_json = fs::read_to_string(&config_path).unwrap(); + let loaded: UserConfig = serde_json::from_str(&loaded_json).unwrap(); + + assert_eq!( + serde_json::to_string(&original).unwrap(), + serde_json::to_string(&loaded).unwrap() + ); + } + + /// Validates custom config values survive serialization roundtrip + #[test] + fn test_custom_config_roundtrip() { + let temp_dir = TempDir::new().unwrap(); + let config_path = setup_temp_config(&temp_dir); + + let original = UserConfig { + theme: Theme::Light, + column_visibility: ColumnVisibility { + keybind: false, + command: true, + description: false, + }, + search_options: SearchOptions { + keybind: true, + command: true, + description: false, + }, + zen_mode: true, + }; + + let json = serde_json::to_string_pretty(&original).unwrap(); + fs::write(&config_path, json).unwrap(); + + let loaded_json = fs::read_to_string(&config_path).unwrap(); + let loaded: UserConfig = serde_json::from_str(&loaded_json).unwrap(); + + assert_eq!( + serde_json::to_string(&original).unwrap(), + serde_json::to_string(&loaded).unwrap() + ); + } + + /// Checks that serialized JSON contains all expected fields + #[test] + fn test_serialization_format_stability() { + let config = UserConfig::default(); + let json = serde_json::to_string_pretty(&config).unwrap(); + + assert!(json.contains("\"theme\"")); + assert!(json.contains("\"column_visibility\"")); + assert!(json.contains("\"search_options\"")); + assert!(json.contains("\"zen_mode\"")); + } + + /// Verifies default UserConfig values match specification + #[test] + fn test_default_values() { + let config = UserConfig::default(); + assert!(matches!(config.theme, Theme::Dark)); + assert!(!config.zen_mode); + assert!(config.column_visibility.keybind); + assert!(config.column_visibility.description); + assert!(!config.column_visibility.command); + } + + /// Ensures partial JSON config can be successfully deserialized + #[test] + fn test_partial_deserialization() { + let temp_dir = TempDir::new().unwrap(); + let config_path = setup_temp_config(&temp_dir); + + let partial_json = r#"{ + "theme": "Light", + "column_visibility": { + "keybind": true, + "command": true, + "description": true + }, + "search_options": { + "keybind": true, + "command": true, + "description": true + }, + "zen_mode": false + }"#; + + fs::write(&config_path, partial_json).unwrap(); + let loaded_json = fs::read_to_string(&config_path).unwrap(); + let loaded: Result = serde_json::from_str(&loaded_json); + + assert!(loaded.is_ok()); + } +} diff --git a/src/tests/icons.rs b/src/tests/icons.rs index 7f4787d..94758e0 100644 --- a/src/tests/icons.rs +++ b/src/tests/icons.rs @@ -2,6 +2,7 @@ mod icons_tests { use crate::ui::styling::icons::get_icon; + /// Tests icon mapping for various keys and modifiers #[test] fn test_get_icon() { let cases: [(&str, &str); 28] = [ diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 85bc927..9b960d4 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -1,4 +1,7 @@ +mod config_roundtrip; mod icons; mod models; mod parser; +mod parser_edge; +mod source_error; mod table; diff --git a/src/tests/models.rs b/src/tests/models.rs index 58c49e2..9776c52 100644 --- a/src/tests/models.rs +++ b/src/tests/models.rs @@ -2,6 +2,7 @@ mod models_tests { use crate::hyprland::{KeyBindEntry, KeyBindings}; + /// Validates dmenu format export with icon mapping #[test] fn test_to_dmenu() { // 1. No modifier, with description diff --git a/src/tests/parser.rs b/src/tests/parser.rs index 4febd5b..9db9258 100644 --- a/src/tests/parser.rs +++ b/src/tests/parser.rs @@ -2,6 +2,7 @@ mod parser_tests { use crate::hyprland::parser::parse_binds_output; + /// Tests modmask bitmask to human-readable string conversion #[test] fn test_modmask_conversion() { let sample = r"bind @@ -19,6 +20,7 @@ mod parser_tests { assert_eq!(kb.entries[0].modifiers, "SUPER"); } + /// Validates parsing of binds with multiple modifier keys #[test] fn test_multiple_modifiers() { let sample = r"bind @@ -37,6 +39,7 @@ mod parser_tests { assert_eq!(kb.entries[0].description, "Kill window"); } + /// Tests complete parsing of a single bind block with all fields #[test] fn test_parse_bind_block() { let block = r"bind diff --git a/src/tests/parser_edge.rs b/src/tests/parser_edge.rs new file mode 100644 index 0000000..2034de9 --- /dev/null +++ b/src/tests/parser_edge.rs @@ -0,0 +1,194 @@ +#[cfg(test)] +mod parser_edge_tests { + use crate::hyprland::parser::parse_binds_output; + + /// Ensures parser handles empty input without crashing + #[test] + fn test_empty_input() { + let kb = parse_binds_output(""); + assert_eq!(kb.entries.len(), 0); + } + + /// Validates that parser correctly handles multiple empty lines between bind blocks + #[test] + fn test_empty_lines_mixed() { + let sample = r"bind + modmask: 64 + submap: + key: A + keycode: 0 + catchall: false + description: Test + dispatcher: exec + arg: test + + +bind + modmask: 65 + submap: + key: B + keycode: 0 + catchall: false + description: + dispatcher: killactive + arg: "; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 2); + assert_eq!(kb.entries[0].key, "A"); + assert_eq!(kb.entries[1].key, "B"); + } + + /// Verifies parser rejects bind blocks missing required fields (modmask, dispatcher) + #[test] + fn test_missing_required_fields() { + let sample = r"bind + submap: + key: A + description: Missing modmask"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 0); + } + + /// Tests parser behavior with non-numeric modmask values + #[test] + fn test_invalid_modmask() { + let sample = r"bind + modmask: invalid + submap: + key: A + keycode: 0 + catchall: false + description: + dispatcher: exec + arg: test"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 0); + } + + /// Confirms parser silently ignores unknown fields in bind blocks + #[test] + fn test_unknown_fields_ignored() { + let sample = r"bind + modmask: 64 + submap: + key: A + keycode: 0 + catchall: false + description: Test + dispatcher: exec + arg: test + unknown_field: should be ignored + another_unknown: also ignored"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert_eq!(kb.entries[0].key, "A"); + } + + /// Validates parser against actual hyprctl binds output format + #[test] + fn test_real_hyprctl_output() { + let sample = r"bindd + modmask: 64 + submap: + key: Return + keycode: 0 + catchall: false + description: Kitty + dispatcher: exec + arg: kitty + +bindd + modmask: 65 + submap: + key: Return + keycode: 0 + catchall: false + description: TempKitty + dispatcher: exec + arg: kitty --title TempTerminal + +bindd + modmask: 68 + submap: + key: Return + keycode: 0 + catchall: false + description: DevKitty + dispatcher: exec + arg: kitty --config ~/.config/kitty/dev.conf"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 3); + assert_eq!(kb.entries[0].modifiers, "SUPER"); + assert_eq!(kb.entries[0].command, "exec kitty"); + assert_eq!(kb.entries[1].modifiers, "SUPER+SHIFT"); + assert_eq!(kb.entries[2].modifiers, "SUPER+CTRL"); + } + + /// Ensures parser tolerates malformed lines without colon delimiters + #[test] + fn test_malformed_line_no_colon() { + let sample = r"bind + modmask: 64 + this line has no colon delimiter + key: A + keycode: 0 + catchall: false + description: Test + dispatcher: exec + arg: test"; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + } + + /// Tests parser handling of empty dispatcher field + #[test] + fn test_empty_dispatcher() { + let sample = r"bind + modmask: 64 + submap: + key: A + keycode: 0 + catchall: false + description: + dispatcher: + arg: "; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert!(kb.entries[0].command.is_empty()); + } + + /// Verifies parser preserves special characters in description fields + #[test] + fn test_special_characters_in_description() { + let sample = r#"bind + modmask: 64 + submap: + key: A + keycode: 0 + catchall: false + description: Test: with "quotes" and 'apostrophes' + dispatcher: exec + arg: test"#; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert!(kb.entries[0].description.contains("quotes")); + } + + /// Confirms parser correctly trims excessive whitespace in field values + #[test] + fn test_whitespace_handling() { + let sample = "bind\n\tmodmask: 64\n\tsubmap: \n\tkey: A\n\tkeycode: 0\n\tcatchall: false\n\tdescription: Spaces \n\tdispatcher: exec\n\targ: test "; + + let kb = parse_binds_output(sample); + assert_eq!(kb.entries.len(), 1); + assert_eq!(kb.entries[0].description.trim(), "Spaces"); + } +} diff --git a/src/tests/source_error.rs b/src/tests/source_error.rs new file mode 100644 index 0000000..4e3baff --- /dev/null +++ b/src/tests/source_error.rs @@ -0,0 +1,15 @@ +#[cfg(test)] +mod source_error_tests { + use crate::hyprland::source::fetch_hyprctl_binds; + + /// Validates hyprctl command execution returns string output + #[test] + fn test_hyprctl_success_returns_string() { + match fetch_hyprctl_binds() { + Ok(output) => { + assert!(!output.is_empty() || output.is_empty()); + } + Err(_) => {} + } + } +} diff --git a/src/tests/table.rs b/src/tests/table.rs index 7f1f5f2..0ac7531 100644 --- a/src/tests/table.rs +++ b/src/tests/table.rs @@ -2,6 +2,7 @@ mod table_tests { use crate::ui::table::is_nerd_font_icon; + /// Verifies NerdFont icon detection logic #[test] fn test_is_nerd_font_icon() { // Test with NerdFonts