max / audiofiles
5 files changed,
+195 insertions,
-1 deletion
| @@ -395,6 +395,7 @@ dependencies = [ | |||
| 395 | 395 | "semver", | |
| 396 | 396 | "serde", | |
| 397 | 397 | "serde_json", | |
| 398 | + | "tempfile", | |
| 398 | 399 | "thiserror 2.0.18", | |
| 399 | 400 | "tokio", | |
| 400 | 401 | "tracing", |
| @@ -22,6 +22,9 @@ serde = { workspace = true } | |||
| 22 | 22 | serde_json = { workspace = true } | |
| 23 | 23 | open = { workspace = true } | |
| 24 | 24 | ||
| 25 | + | [dev-dependencies] | |
| 26 | + | tempfile = "3" | |
| 27 | + | ||
| 25 | 28 | [target.'cfg(target_os = "linux")'.dependencies] | |
| 26 | 29 | eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "glow", "x11", "wayland"] } | |
| 27 | 30 | gtk = "0.18" |
| @@ -156,6 +156,70 @@ fn create_sync_manager( | |||
| 156 | 156 | Some(manager) | |
| 157 | 157 | } | |
| 158 | 158 | ||
| 159 | + | #[cfg(test)] | |
| 160 | + | mod tests { | |
| 161 | + | use super::*; | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn load_api_key_from_file() { | |
| 165 | + | let dir = tempfile::tempdir().unwrap(); | |
| 166 | + | std::fs::write(dir.path().join("sync_api_key"), "test-key-123").unwrap(); | |
| 167 | + | let result = load_api_key(dir.path()); | |
| 168 | + | assert_eq!(result, Some("test-key-123".to_string())); | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | #[test] | |
| 172 | + | fn load_api_key_trims_whitespace() { | |
| 173 | + | let dir = tempfile::tempdir().unwrap(); | |
| 174 | + | std::fs::write(dir.path().join("sync_api_key"), " key-with-spaces \n").unwrap(); | |
| 175 | + | let result = load_api_key(dir.path()); | |
| 176 | + | assert_eq!(result, Some("key-with-spaces".to_string())); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[test] | |
| 180 | + | fn load_api_key_empty_file_returns_none() { | |
| 181 | + | let dir = tempfile::tempdir().unwrap(); | |
| 182 | + | std::fs::write(dir.path().join("sync_api_key"), "").unwrap(); | |
| 183 | + | // Without env vars, empty file → None | |
| 184 | + | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 185 | + | assert_eq!(load_api_key(dir.path()), None); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn load_api_key_whitespace_only_returns_none() { | |
| 191 | + | let dir = tempfile::tempdir().unwrap(); | |
| 192 | + | std::fs::write(dir.path().join("sync_api_key"), " \n ").unwrap(); | |
| 193 | + | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 194 | + | assert_eq!(load_api_key(dir.path()), None); | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | #[test] | |
| 199 | + | fn load_api_key_no_file_returns_none() { | |
| 200 | + | let dir = tempfile::tempdir().unwrap(); | |
| 201 | + | if std::env::var("AF_SYNC_API_KEY").is_err() { | |
| 202 | + | assert_eq!(load_api_key(dir.path()), None); | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn save_api_key_creates_file() { | |
| 208 | + | let dir = tempfile::tempdir().unwrap(); | |
| 209 | + | save_api_key(dir.path(), "saved-key"); | |
| 210 | + | let content = std::fs::read_to_string(dir.path().join("sync_api_key")).unwrap(); | |
| 211 | + | assert_eq!(content, "saved-key"); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn save_and_load_roundtrip() { | |
| 216 | + | let dir = tempfile::tempdir().unwrap(); | |
| 217 | + | save_api_key(dir.path(), "roundtrip-key"); | |
| 218 | + | let result = load_api_key(dir.path()); | |
| 219 | + | assert_eq!(result, Some("roundtrip-key".to_string())); | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 159 | 223 | // ── App ── | |
| 160 | 224 | ||
| 161 | 225 | struct AudioFilesApp { |
| @@ -79,7 +79,7 @@ impl AppTray { | |||
| 79 | 79 | /// | |
| 80 | 80 | /// macOS menu bar working area is 22pt max; 18x18 matches system icon weight | |
| 81 | 81 | /// per Apple HIG. Five vertical bars suggest a waveform silhouette. | |
| 82 | - | fn build_icon() -> Icon { | |
| 82 | + | pub(crate) fn build_icon() -> Icon { | |
| 83 | 83 | const SIZE: usize = 18; | |
| 84 | 84 | let mut rgba = vec![0u8; SIZE * SIZE * 4]; | |
| 85 | 85 | ||
| @@ -103,3 +103,43 @@ fn build_icon() -> Icon { | |||
| 103 | 103 | ||
| 104 | 104 | Icon::from_rgba(rgba, SIZE as u32, SIZE as u32).expect("valid 18x18 RGBA icon") | |
| 105 | 105 | } | |
| 106 | + | ||
| 107 | + | #[cfg(test)] | |
| 108 | + | mod tests { | |
| 109 | + | use super::*; | |
| 110 | + | ||
| 111 | + | #[test] | |
| 112 | + | fn build_icon_produces_valid_18x18_rgba() { | |
| 113 | + | // build_icon should not panic and should produce an icon | |
| 114 | + | let _icon = build_icon(); | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[test] | |
| 118 | + | fn icon_rgba_buffer_has_correct_dimensions() { | |
| 119 | + | const SIZE: usize = 18; | |
| 120 | + | let mut rgba = vec![0u8; SIZE * SIZE * 4]; | |
| 121 | + | ||
| 122 | + | let bar_colour: [u8; 4] = [0x3d, 0x35, 0x30, 0xff]; | |
| 123 | + | let bar_xs: [(usize, usize); 5] = [(2, 4), (5, 7), (8, 10), (11, 13), (14, 16)]; | |
| 124 | + | let bar_heights: [usize; 5] = [6, 12, 18, 10, 5]; | |
| 125 | + | ||
| 126 | + | for (i, &(x_start, x_end)) in bar_xs.iter().enumerate() { | |
| 127 | + | let h = bar_heights[i]; | |
| 128 | + | let y_start = (SIZE - h) / 2; | |
| 129 | + | let y_end = y_start + h; | |
| 130 | + | for y in y_start..y_end { | |
| 131 | + | for x in x_start..x_end { | |
| 132 | + | let offset = (y * SIZE + x) * 4; | |
| 133 | + | rgba[offset..offset + 4].copy_from_slice(&bar_colour); | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | assert_eq!(rgba.len(), SIZE * SIZE * 4); | |
| 139 | + | // Middle bar (index 2) is full height — pixel at (9, 0) should be coloured | |
| 140 | + | let mid_offset = (0 * SIZE + 9) * 4; | |
| 141 | + | assert_eq!(&rgba[mid_offset..mid_offset + 4], &bar_colour); | |
| 142 | + | // Corner (0,0) should be transparent | |
| 143 | + | assert_eq!(&rgba[0..4], &[0, 0, 0, 0]); | |
| 144 | + | } | |
| 145 | + | } |
| @@ -145,3 +145,89 @@ async fn check_once(status: &Arc<Mutex<UpdateStatus>>) { | |||
| 145 | 145 | } | |
| 146 | 146 | } | |
| 147 | 147 | } | |
| 148 | + | ||
| 149 | + | #[cfg(test)] | |
| 150 | + | mod tests { | |
| 151 | + | use super::*; | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn update_status_default_is_inactive() { | |
| 155 | + | let s = UpdateStatus::default(); | |
| 156 | + | assert!(!s.available); | |
| 157 | + | assert!(!s.dismissed); | |
| 158 | + | assert!(s.version.is_empty()); | |
| 159 | + | assert!(s.notes.is_empty()); | |
| 160 | + | assert!(s.download_url.is_empty()); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | #[test] | |
| 164 | + | fn dismiss_sets_flag() { | |
| 165 | + | let checker = UpdateChecker { | |
| 166 | + | status: Arc::new(Mutex::new(UpdateStatus::default())), | |
| 167 | + | }; | |
| 168 | + | assert!(!checker.status.lock().dismissed); | |
| 169 | + | checker.dismiss(); | |
| 170 | + | assert!(checker.status.lock().dismissed); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn should_show_when_available_and_not_dismissed() { | |
| 175 | + | let checker = UpdateChecker { | |
| 176 | + | status: Arc::new(Mutex::new(UpdateStatus { | |
| 177 | + | available: true, | |
| 178 | + | dismissed: false, | |
| 179 | + | version: "1.0.0".to_string(), | |
| 180 | + | ..Default::default() | |
| 181 | + | })), | |
| 182 | + | }; | |
| 183 | + | assert!(checker.should_show()); | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | #[test] | |
| 187 | + | fn should_not_show_when_not_available() { | |
| 188 | + | let checker = UpdateChecker { | |
| 189 | + | status: Arc::new(Mutex::new(UpdateStatus::default())), | |
| 190 | + | }; | |
| 191 | + | assert!(!checker.should_show()); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn should_not_show_when_dismissed() { | |
| 196 | + | let checker = UpdateChecker { | |
| 197 | + | status: Arc::new(Mutex::new(UpdateStatus { | |
| 198 | + | available: true, | |
| 199 | + | dismissed: true, | |
| 200 | + | ..Default::default() | |
| 201 | + | })), | |
| 202 | + | }; | |
| 203 | + | assert!(!checker.should_show()); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn dismiss_then_should_show_returns_false() { | |
| 208 | + | let checker = UpdateChecker { | |
| 209 | + | status: Arc::new(Mutex::new(UpdateStatus { | |
| 210 | + | available: true, | |
| 211 | + | ..Default::default() | |
| 212 | + | })), | |
| 213 | + | }; | |
| 214 | + | assert!(checker.should_show()); | |
| 215 | + | checker.dismiss(); | |
| 216 | + | assert!(!checker.should_show()); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn update_response_deserializes() { | |
| 221 | + | let json = r#"{"version":"1.2.0","url":"https://example.com/dl","notes":"Bug fixes"}"#; | |
| 222 | + | let resp: UpdateResponse = serde_json::from_str(json).unwrap(); | |
| 223 | + | assert_eq!(resp.version, "1.2.0"); | |
| 224 | + | assert_eq!(resp.url, "https://example.com/dl"); | |
| 225 | + | assert_eq!(resp.notes, "Bug fixes"); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn current_version_is_valid_semver() { | |
| 230 | + | Version::parse(CURRENT_VERSION) | |
| 231 | + | .expect("CURRENT_VERSION should be valid semver"); | |
| 232 | + | } | |
| 233 | + | } |