Skip to main content

max / audiofiles

Add unit tests for API key loading, tray icon, and OTA updater Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-25 21:49 UTC
Commit: 9a5396588367d0386863d18cbd0f05a57f6d99db
Parent: ba71f66
5 files changed, +195 insertions, -1 deletion
M Cargo.lock +1
@@ -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 + }