From e91465365deb63c5677d3ea88369825bbdfddfdc Mon Sep 17 00:00:00 2001 From: Tarasov Aleksandr <55220741+arabianq@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:37:22 +0300 Subject: [PATCH] feat: better testing (#131) * add tests * update github actions to include testing step * optimization --- .github/workflows/build.yml | 3 + pwsp-gui/src/gui/input.rs | 67 ++++++++++++++++++++++ pwsp-gui/src/gui/mod.rs | 42 ++++++++++++++ pwsp-gui/src/gui/views/mod.rs | 23 ++++++++ pwsp-lib/src/types/config.rs | 79 ++++++++++++++++++++++++++ pwsp-lib/src/types/pipewire.rs | 81 ++++++++++++++++++++++++++ pwsp-lib/src/types/socket.rs | 85 ++++++++++++++++++++++++++++ pwsp-lib/src/utils/global_hotkeys.rs | 76 +++++++++++++++++++++++++ pwsp-lib/src/utils/gui.rs | 13 +++++ 9 files changed, 469 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e75651..b5e9e15 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,6 +37,9 @@ jobs: with: toolchain: 1.94.1 + - name: Run tests + run: cargo test --locked + - name: Extract all binary names id: cargo-meta run: | diff --git a/pwsp-gui/src/gui/input.rs b/pwsp-gui/src/gui/input.rs index eb1b5bd..ae5b495 100644 --- a/pwsp-gui/src/gui/input.rs +++ b/pwsp-gui/src/gui/input.rs @@ -217,3 +217,70 @@ impl SoundpadGui { // }); } } + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Key, Modifiers}; + + #[test] + fn test_chord_from_event() { + // Valid modifier + key + let mut mods = Modifiers::NONE; + mods.ctrl = true; + let chord = chord_from_event(&mods, &Key::A); + assert_eq!(chord, Some("Ctrl+A".to_string())); + + // Multiple modifiers + mods.shift = true; + let chord = chord_from_event(&mods, &Key::F1); + assert_eq!(chord, Some("Ctrl+Shift+F1".to_string())); + + // Missing modifiers (requires at least one modifier) + let no_mods = Modifiers::NONE; + let chord = chord_from_event(&no_mods, &Key::A); + assert_eq!(chord, None); + + // Invalid keys (e.g. Escape or Enter shouldn't be accepted by chord_from_event) + mods.shift = false; + let chord = chord_from_event(&mods, &Key::Escape); + assert_eq!(chord, None); + } + + #[test] + fn test_parse_chord() { + // Valid Ctrl+A + let res = parse_chord("Ctrl+A"); + assert!(res.is_some()); + let (mods, key) = res.unwrap(); + assert!(mods.ctrl); + assert!(!mods.alt); + assert!(!mods.shift); + assert_eq!(key, Key::A); + + // Valid Ctrl+Shift+F12 + let res = parse_chord("Ctrl+Shift+F12"); + assert!(res.is_some()); + let (mods, key) = res.unwrap(); + assert!(mods.ctrl); + assert!(mods.shift); + assert!(!mods.alt); + assert_eq!(key, Key::F12); + + // Valid Ctrl+Alt+Shift+Super+B + let res = parse_chord("Ctrl+Alt+Shift+Super+B"); + assert!(res.is_some()); + let (mods, key) = res.unwrap(); + assert!(mods.ctrl); + assert!(mods.alt); + assert!(mods.shift); + assert!(mods.command); // Super maps to command in egui Modifiers + assert_eq!(key, Key::B); + + // Invalid keys/chords + assert!(parse_chord("").is_none()); + assert!(parse_chord("Ctrl+").is_none()); + assert!(parse_chord("Ctrl+Escape").is_none()); + assert!(parse_chord("Invalid+A").is_none()); + } +} diff --git a/pwsp-gui/src/gui/mod.rs b/pwsp-gui/src/gui/mod.rs index 61b979e..6e588d4 100644 --- a/pwsp-gui/src/gui/mod.rs +++ b/pwsp-gui/src/gui/mod.rs @@ -294,3 +294,45 @@ pub async fn run() -> Result<()> { Err(e) => Err(anyhow!(e.to_string())), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_filtered_files() { + let mut gui = SoundpadGui { + app_state: AppState::default(), + config: GuiConfig::default(), + audio_player_state: AudioPlayerState::default(), + audio_player_state_shared: Arc::new(Mutex::new(AudioPlayerState::default())), + }; + + // Create some dummy paths + // We will mock path properties using standard Rust PathBuf + let dir_a = PathBuf::from("a_dir"); + let file_b = PathBuf::from("b_file.mp3"); + let file_c = PathBuf::from("c_file.wav"); + let file_txt = PathBuf::from("invalid.txt"); + + gui.app_state.listed_files.insert(dir_a.clone()); + gui.app_state.listed_files.insert(file_b.clone()); + gui.app_state.listed_files.insert(file_c.clone()); + gui.app_state.listed_files.insert(file_txt.clone()); + + // Note: is_dir() check in get_filtered_files relies on physical filesystem properties. + // On the real OS filesystem, these paths don't exist, so they are treated as files. + // Unsupported extensions (like .txt) will be filtered out. + // So we expect only file_b and file_c, sorted alphabetically. + let filtered = gui.get_filtered_files(); + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0], file_b); + assert_eq!(filtered[1], file_c); + + // Test search query + gui.app_state.search_query = "c_fi".to_string(); + let filtered_search = gui.get_filtered_files(); + assert_eq!(filtered_search.len(), 1); + assert_eq!(filtered_search[0], file_c); + } +} diff --git a/pwsp-gui/src/gui/views/mod.rs b/pwsp-gui/src/gui/views/mod.rs index d73949c..360078d 100644 --- a/pwsp-gui/src/gui/views/mod.rs +++ b/pwsp-gui/src/gui/views/mod.rs @@ -30,3 +30,26 @@ impl SoundpadGui { self.draw_footer(ui); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_volume_icon() { + assert_eq!(SoundpadGui::get_volume_icon(0.8), ICON_VOLUME_UP.codepoint); + assert_eq!(SoundpadGui::get_volume_icon(0.0), ICON_VOLUME_OFF.codepoint); + assert_eq!( + SoundpadGui::get_volume_icon(-0.1), + ICON_VOLUME_OFF.codepoint + ); + assert_eq!( + SoundpadGui::get_volume_icon(0.2), + ICON_VOLUME_MUTE.codepoint + ); + assert_eq!( + SoundpadGui::get_volume_icon(0.5), + ICON_VOLUME_DOWN.codepoint + ); + } +} diff --git a/pwsp-lib/src/types/config.rs b/pwsp-lib/src/types/config.rs index 26c074d..b8c4edd 100644 --- a/pwsp-lib/src/types/config.rs +++ b/pwsp-lib/src/types/config.rs @@ -218,3 +218,82 @@ impl HotkeyConfig { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gui_config_default() { + let config = GuiConfig::default(); + assert_eq!(config.scale_factor, 1.0); + assert_eq!(config.left_panel_width, 280.0); + assert!(!config.save_volume); + assert_eq!(config.preferred_theme, PreferredTheme::System); + } + + #[test] + fn test_hotkey_config_operations() { + let mut config = HotkeyConfig::default(); + assert!(config.slots.is_empty()); + + let req = Request::ping(); + config.set_slot("slot1".to_string(), req.clone()); + assert_eq!(config.slots.len(), 1); + assert_eq!(config.slots[0].slot, "slot1"); + assert_eq!(config.slots[0].action, req); + assert!(config.slots[0].key_chord.is_none()); + + // Test find_slot + let found = config.find_slot("slot1"); + assert!(found.is_some()); + assert_eq!(found.unwrap().slot, "slot1"); + + // Test set_key_chord + let updated = config.set_key_chord("slot1", Some("Ctrl+A".to_string())); + assert!(updated); + assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+A")); + + // Test set_key_chord for non-existent slot + let updated_non_existent = config.set_key_chord("slot2", Some("Ctrl+B".to_string())); + assert!(!updated_non_existent); + + // Test find_slot_mut + let found_mut = config.find_slot_mut("slot1"); + assert!(found_mut.is_some()); + found_mut.unwrap().key_chord = Some("Ctrl+B".to_string()); + assert_eq!(config.slots[0].key_chord.as_deref(), Some("Ctrl+B")); + + // Test slots_for_chord + let slots = config.slots_for_chord("Ctrl+B"); + assert_eq!(slots.len(), 1); + assert_eq!(slots[0].slot, "slot1"); + + let empty_slots = config.slots_for_chord("Ctrl+A"); + assert!(empty_slots.is_empty()); + + // Test remove_slot + let removed = config.remove_slot("slot1"); + assert!(removed); + assert!(config.slots.is_empty()); + + let removed_non_existent = config.remove_slot("slot1"); + assert!(!removed_non_existent); + } + + #[test] + fn test_hotkey_config_conflicts() { + let mut config = HotkeyConfig::default(); + config.set_slot("slot1".to_string(), Request::ping()); + config.set_slot("slot2".to_string(), Request::ping()); + config.set_slot("slot3".to_string(), Request::ping()); + + config.set_key_chord("slot1", Some("Ctrl+A".to_string())); + config.set_key_chord("slot2", Some("Ctrl+A".to_string())); // Conflict with slot1 + config.set_key_chord("slot3", Some("Ctrl+B".to_string())); + + let conflicts = config.find_conflicts(); + assert_eq!(conflicts.len(), 1); + assert!(conflicts.contains(&("slot1", "slot2")) || conflicts.contains(&("slot2", "slot1"))); + } +} diff --git a/pwsp-lib/src/types/pipewire.rs b/pwsp-lib/src/types/pipewire.rs index 42038d3..a0f7cb0 100644 --- a/pwsp-lib/src/types/pipewire.rs +++ b/pwsp-lib/src/types/pipewire.rs @@ -72,3 +72,84 @@ impl AudioDevice { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_device_new() { + let device = AudioDevice::new( + 1, + Some("NickName"), + Some("Description"), + Some("Name"), + DeviceType::Input, + ); + assert_eq!(device.id, 1); + assert_eq!(device.nick, "NickName"); + assert_eq!(device.name, "Name"); + assert_eq!(device.device_type, DeviceType::Input); + + // Fallbacks for nick + let device_no_nick = + AudioDevice::new(2, None, Some("Desc"), Some("Name"), DeviceType::Output); + assert_eq!(device_no_nick.nick, "Desc"); + + let device_no_desc = AudioDevice::new(3, None, None, Some("Name"), DeviceType::Output); + assert_eq!(device_no_desc.nick, "Name"); + } + + #[test] + fn test_audio_device_add_port() { + let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input); + + let port_fl = Port { + node_id: 1, + port_id: 10, + name: "input_FL".to_string(), + }; + let port_fr = Port { + node_id: 1, + port_id: 11, + name: "input_FR".to_string(), + }; + + device.add_port(port_fl.clone()); + device.add_port(port_fr.clone()); + + assert_eq!(device.input_fl, Some(port_fl)); + assert_eq!(device.input_fr, Some(port_fr)); + + // Test output ports + let port_out_fl = Port { + node_id: 1, + port_id: 12, + name: "output_FL".to_string(), + }; + let port_out_fr = Port { + node_id: 1, + port_id: 13, + name: "capture_FR".to_string(), + }; + + device.add_port(port_out_fl.clone()); + device.add_port(port_out_fr.clone()); + + assert_eq!(device.output_fl, Some(port_out_fl)); + assert_eq!(device.output_fr, Some(port_out_fr)); + + // Test MONO ports + let mut device_mono = + AudioDevice::new(2, None, None, Some("mono-device"), DeviceType::Input); + let port_mono = Port { + node_id: 2, + port_id: 20, + name: "input_MONO".to_string(), + }; + device_mono.add_port(port_mono.clone()); + + assert_eq!(device_mono.input_fl, Some(port_mono.clone())); + assert_eq!(device_mono.input_fr, Some(port_mono)); + } +} diff --git a/pwsp-lib/src/types/socket.rs b/pwsp-lib/src/types/socket.rs index 307c4e6..728a03a 100644 --- a/pwsp-lib/src/types/socket.rs +++ b/pwsp-lib/src/types/socket.rs @@ -238,3 +238,88 @@ impl Response { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_response_new() { + let res = Response::new(true, "success-msg"); + assert!(res.status); + assert_eq!(res.message, "success-msg"); + } + + #[test] + fn test_request_constructors() { + // test ping + let req_ping = Request::ping(); + assert_eq!(req_ping.name, "ping"); + assert!(req_ping.args.is_empty()); + + // test kill + let req_kill = Request::kill(); + assert_eq!(req_kill.name, "kill"); + + // test pause (with and without id) + let req_pause_no_id = Request::pause(None); + assert_eq!(req_pause_no_id.name, "pause"); + assert!(req_pause_no_id.args.is_empty()); + + let req_pause_with_id = Request::pause(Some(42)); + assert_eq!(req_pause_with_id.name, "pause"); + assert_eq!( + req_pause_with_id.args.get("id").map(|s| s.as_str()), + Some("42") + ); + + // test play + let req_play = Request::play("/path/to/sound.mp3", true); + assert_eq!(req_play.name, "play"); + assert_eq!( + req_play.args.get("file_path").map(|s| s.as_str()), + Some("/path/to/sound.mp3") + ); + assert_eq!( + req_play.args.get("concurrent").map(|s| s.as_str()), + Some("true") + ); + + // test set_volume + let req_volume = Request::set_volume(0.8, Some(10)); + assert_eq!(req_volume.name, "set_volume"); + assert_eq!( + req_volume.args.get("volume").map(|s| s.as_str()), + Some("0.8") + ); + assert_eq!(req_volume.args.get("id").map(|s| s.as_str()), Some("10")); + + // test set_hotkey_action_and_key + let action = Request::ping(); + let req_hotkey_action_and_key = + Request::set_hotkey_action_and_key("slot1", &action, "Ctrl+P"); + assert_eq!(req_hotkey_action_and_key.name, "set_hotkey_action_and_key"); + assert_eq!( + req_hotkey_action_and_key + .args + .get("slot") + .map(|s| s.as_str()), + Some("slot1") + ); + assert_eq!( + req_hotkey_action_and_key + .args + .get("key_chord") + .map(|s| s.as_str()), + Some("Ctrl+P") + ); + let action_json = serde_json::to_string(&action).unwrap(); + assert_eq!( + req_hotkey_action_and_key + .args + .get("action") + .map(|s| s.as_str()), + Some(action_json.as_str()) + ); + } +} diff --git a/pwsp-lib/src/utils/global_hotkeys.rs b/pwsp-lib/src/utils/global_hotkeys.rs index c94bc8b..3a7575e 100644 --- a/pwsp-lib/src/utils/global_hotkeys.rs +++ b/pwsp-lib/src/utils/global_hotkeys.rs @@ -199,3 +199,79 @@ pub async fn start_global_hotkey_listener() { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_modifier_state() { + let mut state = ModifierState::new(); + assert!(!state.any_active()); + + // Press Ctrl + state.update(KeyCode::KEY_LEFTCTRL, true); + assert!(state.ctrl); + assert!(state.any_active()); + + // Release Ctrl + state.update(KeyCode::KEY_LEFTCTRL, false); + assert!(!state.ctrl); + assert!(!state.any_active()); + + // Press multiple modifiers + state.update(KeyCode::KEY_RIGHTALT, true); + state.update(KeyCode::KEY_LEFTSHIFT, true); + assert!(state.alt); + assert!(state.shift); + assert!(state.any_active()); + + // Update a non-modifier key + state.update(KeyCode::KEY_A, true); + // Modifier states should remain unchanged + assert!(state.alt); + assert!(state.shift); + } + + #[test] + fn test_is_modifier() { + assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTCTRL)); + assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTCTRL)); + assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTALT)); + assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTALT)); + assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTSHIFT)); + assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTSHIFT)); + assert!(ModifierState::is_modifier(KeyCode::KEY_LEFTMETA)); + assert!(ModifierState::is_modifier(KeyCode::KEY_RIGHTMETA)); + + assert!(!ModifierState::is_modifier(KeyCode::KEY_A)); + assert!(!ModifierState::is_modifier(KeyCode::KEY_1)); + } + + #[test] + fn test_evdev_key_name() { + assert_eq!(evdev_key_name(KeyCode::KEY_A), Some("A")); + assert_eq!(evdev_key_name(KeyCode::KEY_Z), Some("Z")); + assert_eq!(evdev_key_name(KeyCode::KEY_0), Some("0")); + assert_eq!(evdev_key_name(KeyCode::KEY_F1), Some("F1")); + assert_eq!(evdev_key_name(KeyCode::KEY_F12), Some("F12")); + assert_eq!(evdev_key_name(KeyCode::KEY_ENTER), None); + } + + #[test] + fn test_build_chord() { + let mut modifiers = ModifierState::new(); + + assert_eq!(build_chord(&modifiers, "A"), "A"); + + modifiers.ctrl = true; + assert_eq!(build_chord(&modifiers, "A"), "Ctrl+A"); + + modifiers.shift = true; + assert_eq!(build_chord(&modifiers, "B"), "Ctrl+Shift+B"); + + modifiers.alt = true; + modifiers.meta = true; + assert_eq!(build_chord(&modifiers, "F5"), "Ctrl+Alt+Shift+Super+F5"); + } +} diff --git a/pwsp-lib/src/utils/gui.rs b/pwsp-lib/src/utils/gui.rs index 45adf42..c40e264 100644 --- a/pwsp-lib/src/utils/gui.rs +++ b/pwsp-lib/src/utils/gui.rs @@ -139,3 +139,16 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc