diff --git a/crates/librarian-app/src/icons.rs b/crates/librarian-app/src/icons.rs index 10a401a..585ccba 100644 --- a/crates/librarian-app/src/icons.rs +++ b/crates/librarian-app/src/icons.rs @@ -79,3 +79,56 @@ pub fn extract_icons(worker: &ShellWorker, keys: Vec) -> Vec<(IconKey, }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn ext(name: &str) -> IconKey { + IconKey::Ext(name.to_string()) + } + + #[test] + fn take_unrequested_returns_each_key_once_across_calls() { + let mut cache = IconCache::default(); + + // First sight of these keys: all are handed back for extraction. + let first = cache.take_unrequested([IconKey::Folder, ext("txt")]); + assert_eq!(first, vec![IconKey::Folder, ext("txt")]); + + // They're now in-flight, so asking again yields nothing — each icon is + // only extracted once, even before its handle has come back. + assert!( + cache + .take_unrequested([IconKey::Folder, ext("txt")]) + .is_empty() + ); + + // A genuinely new key still comes through. + assert_eq!(cache.take_unrequested([ext("rs")]), vec![ext("rs")]); + } + + #[test] + fn take_unrequested_dedupes_within_a_single_call() { + let mut cache = IconCache::default(); + // The same key repeated in one batch is requested only once. + let out = cache.take_unrequested([ext("txt"), ext("txt"), IconKey::Folder]); + assert_eq!(out, vec![ext("txt"), IconKey::Folder]); + } + + #[test] + fn cached_keys_are_not_requested_again() { + let mut cache = IconCache::default(); + let image = IconImage { + width: 1, + height: 1, + rgba: vec![0, 0, 0, 0], + }; + + assert!(cache.get(&IconKey::Folder).is_none()); + cache.insert(IconKey::Folder, image); + // Once a handle is cached, get() resolves it and take_unrequested skips it. + assert!(cache.get(&IconKey::Folder).is_some()); + assert!(cache.take_unrequested([IconKey::Folder]).is_empty()); + } +} diff --git a/crates/librarian-app/src/rows.rs b/crates/librarian-app/src/rows.rs index c8d6fbf..e7f6fb8 100644 --- a/crates/librarian-app/src/rows.rs +++ b/crates/librarian-app/src/rows.rs @@ -194,6 +194,29 @@ pub fn format_time(time: SystemTime) -> String { #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; + use std::time::{Duration, UNIX_EPOCH}; + + use librarian_core::{Attributes, EntryKind}; + + fn file_entry(name: &str, size: u64) -> Entry { + Entry { + name: name.to_string(), + path: PathBuf::from(format!(r"C:\dir\{name}")), + kind: EntryKind::File, + size, + modified: None, + created: None, + attrs: Attributes::default(), + } + } + + fn dir_entry(name: &str) -> Entry { + Entry { + kind: EntryKind::Directory, + ..file_entry(name, 0) + } + } #[test] fn human_size_scales_units() { @@ -202,4 +225,168 @@ mod tests { assert_eq!(human_size(1024), "1.0 KB"); assert_eq!(human_size(1_500_000), "1.4 MB"); } + + #[test] + fn type_label_is_folder_or_uppercase_extension() { + assert_eq!(type_label(&dir_entry("Photos")), "File folder"); + assert_eq!(type_label(&file_entry("notes.txt", 0)), "TXT File"); + // No extension falls back to the bare "File". + assert_eq!(type_label(&file_entry("README", 0)), "File"); + // A leading-dot name (no real extension) is also just "File". + assert_eq!(type_label(&file_entry(".gitignore", 0)), "File"); + } + + #[test] + fn file_row_carries_size_type_and_extension_icon() { + let row = row_from_entry(&file_entry("notes.txt", 42)); + assert_eq!(row.label, "notes.txt"); + assert!(!row.is_container); + assert_eq!(row.size, Some(42)); + assert_eq!(row.type_label, "TXT File"); + assert_eq!(row.icon, IconKey::Ext("txt".to_string())); + assert_eq!( + row.target, + Location::Path(PathBuf::from(r"C:\dir\notes.txt")) + ); + } + + #[test] + fn dir_row_is_a_container_with_no_size() { + let row = row_from_entry(&dir_entry("Photos")); + assert!(row.is_container); + assert_eq!(row.size, None); + assert_eq!(row.icon, IconKey::Folder); + assert_eq!(row.type_label, "File folder"); + } + + #[test] + fn drive_row_formats_label_and_falls_back_to_kind() { + let drive = DriveInfo { + letter: 'C', + root: PathBuf::from(r"C:\"), + label: "Windows".to_string(), + kind: DriveKind::Fixed, + total_bytes: 0, + free_bytes: 0, + }; + let row = row_from_drive(&drive); + assert_eq!(row.label, "Windows (C:)"); + assert_eq!(row.type_label, "Local Disk"); + assert!(row.is_container); + assert_eq!(row.size, None); + assert_eq!(row.icon, IconKey::Path(PathBuf::from(r"C:\"))); + + // An unlabeled drive shows its kind in place of a name. + let unlabeled = DriveInfo { + label: String::new(), + kind: DriveKind::Removable, + ..drive + }; + let row = row_from_drive(&unlabeled); + assert_eq!(row.label, "Removable Disk (C:)"); + assert_eq!(row.type_label, "Removable Disk"); + } + + #[test] + fn drive_kind_names_cover_every_variant() { + let cases = [ + (DriveKind::Fixed, "Local Disk"), + (DriveKind::Removable, "Removable Disk"), + (DriveKind::Network, "Network Drive"), + (DriveKind::CdRom, "CD Drive"), + (DriveKind::RamDisk, "RAM Disk"), + (DriveKind::Unknown, "Disk"), + ]; + for (kind, expected) in cases { + assert_eq!(drive_kind_name(kind), expected); + } + } + + #[test] + fn known_folder_row_uses_its_own_shell_icon() { + let folder = KnownFolder { + name: "Downloads", + path: PathBuf::from(r"C:\Users\j\Downloads"), + }; + let row = row_from_known(&folder); + assert_eq!(row.label, "Downloads"); + assert_eq!(row.type_label, "File folder"); + assert!(row.is_container); + assert_eq!( + row.icon, + IconKey::Path(PathBuf::from(r"C:\Users\j\Downloads")) + ); + } + + #[test] + fn distro_row_targets_the_unc_root_with_the_wsl_icon() { + let distro = WslDistro { + name: "Ubuntu".to_string(), + }; + let row = row_from_distro(&distro); + assert_eq!(row.label, "Ubuntu"); + assert_eq!(row.icon, IconKey::Wsl); + assert_eq!(row.type_label, "Linux distribution"); + assert_eq!(row.target, Location::Path(distro_unc_path("Ubuntu"))); + } + + #[test] + fn search_hit_label_is_relative_to_the_search_root() { + let root = Path::new(r"C:\proj"); + let hit = SearchHit { + path: PathBuf::from(r"C:\proj\src\main.rs"), + matches: None, + is_dir: false, + }; + let row = row_from_hit(&hit, root); + // The path is shown relative to the root, like an editor's search panel. + assert_eq!(row.label, r"src\main.rs"); + assert!(!row.is_container); + assert_eq!(row.type_label, "RS File"); + assert_eq!(row.icon, IconKey::Ext("rs".to_string())); + } + + #[test] + fn search_hit_directory_navigates() { + let root = Path::new(r"C:\proj"); + let hit = SearchHit { + path: PathBuf::from(r"C:\proj\src"), + matches: None, + is_dir: true, + }; + let row = row_from_hit(&hit, root); + assert!(row.is_container); + assert_eq!(row.type_label, "File folder"); + assert_eq!(row.icon, IconKey::Folder); + } + + #[test] + fn contents_hit_reports_match_count_with_pluralization() { + let root = Path::new(r"C:\proj"); + let hit = |n: u64| SearchHit { + path: PathBuf::from(r"C:\proj\notes.txt"), + matches: Some(n), + is_dir: false, + }; + assert_eq!(row_from_hit(&hit(1), root).type_label, "1 match"); + assert_eq!(row_from_hit(&hit(3), root).type_label, "3 matches"); + } + + #[test] + fn format_time_renders_minute_precision() { + // Local time varies by host, so assert the shape, not an exact instant: + // `YYYY-MM-DD HH:MM`, exactly 16 chars, all fields zero-padded. + let t = UNIX_EPOCH + Duration::from_secs(1_700_000_000); + let s = format_time(t); + assert_eq!(s.len(), 16, "expected `YYYY-MM-DD HH:MM`, got {s:?}"); + let bytes = s.as_bytes(); + for (i, b) in bytes.iter().enumerate() { + match i { + 4 | 7 => assert_eq!(*b, b'-'), + 10 => assert_eq!(*b, b' '), + 13 => assert_eq!(*b, b':'), + _ => assert!(b.is_ascii_digit(), "non-digit at {i} in {s:?}"), + } + } + } } diff --git a/crates/librarian-win/src/util.rs b/crates/librarian-win/src/util.rs index 1b68d97..47a6204 100644 --- a/crates/librarian-win/src/util.rs +++ b/crates/librarian-win/src/util.rs @@ -11,3 +11,34 @@ pub(crate) fn wide_to_string(buf: &[u16]) -> String { let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); String::from_utf16_lossy(&buf[..len]) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn to_wide_encodes_and_nul_terminates() { + // ASCII encodes one u16 per char, with a trailing NUL the FFI needs. + assert_eq!(to_wide("Hi"), vec![0x48, 0x69, 0x00]); + // The empty string is just the terminator. + assert_eq!(to_wide(""), vec![0x00]); + } + + #[test] + fn wide_to_string_stops_at_first_nul() { + // Bytes past the NUL (here a stray 'X') are ignored — buffers come back + // from Win32 padded with garbage after the terminator. + assert_eq!(wide_to_string(&[0x48, 0x69, 0x00, 0x58]), "Hi"); + // No NUL at all: the whole buffer is decoded. + assert_eq!(wide_to_string(&[0x48, 0x69]), "Hi"); + // A leading NUL is the empty string. + assert_eq!(wide_to_string(&[0x00, 0x69]), ""); + } + + #[test] + fn round_trips_through_utf16_including_non_ascii() { + for s in ["", "C:\\Windows", "Ubuntu", "café — résumé", "日本語"] { + assert_eq!(wide_to_string(&to_wide(s)), s); + } + } +}