diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f261111..bf3a4bc 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -47,3 +47,95 @@ impl Database { Ok(()) } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_in_memory() { + let db = Database::open(Path::new(":memory:")); + assert!(db.is_ok()); + } + + #[test] + fn migrate_creates_tables() { + let db = Database::open(Path::new(":memory:")).unwrap(); + db.migrate().unwrap(); + + let conn = db.conn(); + let tables: Vec = conn + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(tables.contains(&"connections".to_string())); + assert!(tables.contains(&"credentials".to_string())); + assert!(tables.contains(&"groups".to_string())); + assert!(tables.contains(&"host_keys".to_string())); + assert!(tables.contains(&"settings".to_string())); + assert!(tables.contains(&"themes".to_string())); + assert!(tables.contains(&"ssh_keys".to_string())); + assert!(tables.contains(&"connection_history".to_string())); + } + + #[test] + fn migrate_is_idempotent() { + let db = Database::open(Path::new(":memory:")).unwrap(); + db.migrate().unwrap(); + db.migrate().unwrap(); // second run must not error + } + + #[test] + fn foreign_keys_enabled() { + let db = Database::open(Path::new(":memory:")).unwrap(); + let conn = db.conn(); + let fk_enabled: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(fk_enabled, 1); + } + + #[test] + fn wal_mode_enabled() { + let db = Database::open(Path::new(":memory:")).unwrap(); + // In-memory databases use "memory" journal mode, but WAL is set + // for file-backed DBs. Just verify the pragma doesn't error. + let conn = db.conn(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .unwrap(); + // In-memory always reports "memory"; file-backed would report "wal". + assert!(!mode.is_empty()); + } + + #[test] + fn clone_shares_connection() { + let db = Database::open(Path::new(":memory:")).unwrap(); + db.migrate().unwrap(); + + let db2 = db.clone(); + // Write through one handle, read through the other. + db.conn() + .execute( + "INSERT INTO settings (key, value) VALUES ('test', 'yes')", + [], + ) + .unwrap(); + + let val: String = db2 + .conn() + .query_row("SELECT value FROM settings WHERE key = 'test'", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(val, "yes"); + } +} diff --git a/src-tauri/src/rdp/input.rs b/src-tauri/src/rdp/input.rs index 9dfcecd..4bc7b64 100644 --- a/src-tauri/src/rdp/input.rs +++ b/src-tauri/src/rdp/input.rs @@ -190,3 +190,121 @@ pub fn is_extended(scancode: u32) -> bool { pub fn scancode_value(scancode: u32) -> u8 { (scancode & 0xFF) as u8 } + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── scancode lookup ────────────────────────────────────────────────────── + + #[test] + fn escape_key_maps_correctly() { + assert_eq!(js_key_to_scancode("Escape"), Some(0x0001)); + } + + #[test] + fn letter_keys_map_correctly() { + assert_eq!(js_key_to_scancode("KeyA"), Some(0x001E)); + assert_eq!(js_key_to_scancode("KeyZ"), Some(0x002C)); + } + + #[test] + fn function_keys_map_correctly() { + assert_eq!(js_key_to_scancode("F1"), Some(0x003B)); + assert_eq!(js_key_to_scancode("F12"), Some(0x0058)); + } + + #[test] + fn enter_key_maps_correctly() { + assert_eq!(js_key_to_scancode("Enter"), Some(0x001C)); + } + + #[test] + fn space_key_maps_correctly() { + assert_eq!(js_key_to_scancode("Space"), Some(0x0039)); + } + + #[test] + fn unknown_key_returns_none() { + assert_eq!(js_key_to_scancode("FakeKey"), None); + assert_eq!(js_key_to_scancode(""), None); + } + + // ── extended key detection ─────────────────────────────────────────────── + + #[test] + fn non_extended_key_detected() { + assert!(!is_extended(0x001E)); // KeyA + assert!(!is_extended(0x0001)); // Escape + } + + #[test] + fn extended_key_detected() { + assert!(is_extended(0xE038)); // AltRight + assert!(is_extended(0xE01D)); // ControlRight + assert!(is_extended(0xE048)); // ArrowUp + } + + #[test] + fn arrow_keys_are_extended() { + let arrows = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + for key in arrows { + let sc = js_key_to_scancode(key).unwrap(); + assert!(is_extended(sc), "{key} should be extended"); + } + } + + // ── scancode value extraction ──────────────────────────────────────────── + + #[test] + fn scancode_value_extracts_low_byte() { + assert_eq!(scancode_value(0x001E), 0x1E); // KeyA + assert_eq!(scancode_value(0xE038), 0x38); // AltRight — low byte only + } + + #[test] + fn numpad_enter_is_extended() { + let sc = js_key_to_scancode("NumpadEnter").unwrap(); + assert!(is_extended(sc)); + assert_eq!(scancode_value(sc), 0x1C); // same low byte as regular Enter + } + + // ── mouse flags ───────────────────────────────────────────────────────── + + #[test] + fn mouse_flags_are_distinct_bits() { + let flags = [ + mouse_flags::MOVE, + mouse_flags::BUTTON1, + mouse_flags::BUTTON2, + mouse_flags::BUTTON3, + mouse_flags::DOWN, + mouse_flags::WHEEL, + mouse_flags::WHEEL_NEG, + mouse_flags::HWHEEL, + ]; + // Each flag should be a single power of 2 (or a known composite). + for &f in &flags { + assert!(f > 0, "flag should be nonzero"); + assert!(f.count_ones() == 1, "flag {:#06x} should be a single bit", f); + } + } + + #[test] + fn left_click_down_composable() { + let event = mouse_flags::BUTTON1 | mouse_flags::DOWN; + assert_eq!(event & mouse_flags::BUTTON1, mouse_flags::BUTTON1); + assert_eq!(event & mouse_flags::DOWN, mouse_flags::DOWN); + } + + // ── coverage: every key in the map is reachable ───────────────────────── + + #[test] + fn scancode_map_has_expected_size() { + // 13 Fn/Esc + 14 number row + 14 QWERTY + 13 home row + 12 bottom + // + 8 modifiers + 9 nav + 4 arrows + 17 numpad + 17 media + 3 intl = ~124 + assert!(SCANCODE_MAP.len() >= 100, "map should have 100+ entries, got {}", SCANCODE_MAP.len()); + } +} diff --git a/src-tauri/src/theme/mod.rs b/src-tauri/src/theme/mod.rs index 614aae8..cc5fc58 100644 --- a/src-tauri/src/theme/mod.rs +++ b/src-tauri/src/theme/mod.rs @@ -337,3 +337,128 @@ fn map_theme_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { is_builtin: row.get(21)?, }) } + +// ── tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + + fn make_service() -> ThemeService { + let db = Database::open(std::path::Path::new(":memory:")).unwrap(); + db.migrate().unwrap(); + ThemeService::new(db) + } + + #[test] + fn list_empty_before_seed() { + let svc = make_service(); + assert!(svc.list().is_empty()); + } + + #[test] + fn seed_builtins_creates_seven_themes() { + let svc = make_service(); + svc.seed_builtins(); + let themes = svc.list(); + assert_eq!(themes.len(), 7); + } + + #[test] + fn seed_builtins_is_idempotent() { + let svc = make_service(); + svc.seed_builtins(); + svc.seed_builtins(); // second run must not duplicate + assert_eq!(svc.list().len(), 7); + } + + #[test] + fn all_builtins_marked_as_builtin() { + let svc = make_service(); + svc.seed_builtins(); + for theme in svc.list() { + assert!(theme.is_builtin, "{} should be marked as builtin", theme.name); + } + } + + #[test] + fn builtin_names_correct() { + let svc = make_service(); + svc.seed_builtins(); + let names: Vec = svc.list().into_iter().map(|t| t.name).collect(); + assert!(names.contains(&"Dracula".to_string())); + assert!(names.contains(&"Nord".to_string())); + assert!(names.contains(&"Monokai".to_string())); + assert!(names.contains(&"One Dark".to_string())); + assert!(names.contains(&"Solarized Dark".to_string())); + assert!(names.contains(&"Gruvbox Dark".to_string())); + assert!(names.contains(&"MobaXTerm Classic".to_string())); + } + + #[test] + fn get_by_name_returns_theme() { + let svc = make_service(); + svc.seed_builtins(); + let theme = svc.get_by_name("Dracula"); + assert!(theme.is_some()); + let t = theme.unwrap(); + assert_eq!(t.name, "Dracula"); + assert_eq!(t.background, "#282a36"); + assert_eq!(t.foreground, "#f8f8f2"); + } + + #[test] + fn get_by_name_missing_returns_none() { + let svc = make_service(); + svc.seed_builtins(); + assert!(svc.get_by_name("Nonexistent Theme").is_none()); + } + + #[test] + fn get_by_name_is_case_sensitive() { + let svc = make_service(); + svc.seed_builtins(); + assert!(svc.get_by_name("dracula").is_none()); // lowercase should not match + } + + #[test] + fn all_themes_have_valid_hex_colors() { + let svc = make_service(); + svc.seed_builtins(); + for theme in svc.list() { + let colors = [ + &theme.foreground, &theme.background, &theme.cursor, + &theme.black, &theme.red, &theme.green, &theme.yellow, + &theme.blue, &theme.magenta, &theme.cyan, &theme.white, + &theme.bright_black, &theme.bright_red, &theme.bright_green, + &theme.bright_yellow, &theme.bright_blue, &theme.bright_magenta, + &theme.bright_cyan, &theme.bright_white, + ]; + for color in colors { + assert!( + color.starts_with('#') && color.len() == 7, + "Theme '{}' has invalid color: '{}'", + theme.name, + color + ); + } + } + } + + #[test] + fn list_ordered_builtin_first_then_name() { + let svc = make_service(); + svc.seed_builtins(); + let themes = svc.list(); + // All are builtin, so should be ordered by name (case-insensitive) + for w in themes.windows(2) { + assert!( + w[0].name.to_lowercase() <= w[1].name.to_lowercase(), + "'{}' should come before '{}'", + w[0].name, + w[1].name + ); + } + } +}