a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
16

Configure Feed

Select the types of activity you want to include in your feed.

add more tests

Tsiry Sandratraina (Jul 5, 2026, 9:44 AM +0300) 9ac12be2 c6edcad0

+779 -8
+7 -8
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: main 4 4 5 - engine: nixery 5 + engine: nixos 6 6 7 7 dependencies: 8 - nixpkgs: 9 - - cargo 10 - - rustc 11 - - rustfmt 12 - - gcc 13 - - pkg-config 14 - - alsa-lib 8 + - cargo 9 + - rustc 10 + - rustfmt 11 + - gcc 12 + - pkg-config 13 + - alsa-lib 15 14 16 15 steps: 17 16 - name: nix build
+147
crates/fin-config/src/lib.rs
··· 241 241 self.servers.get(next) 242 242 } 243 243 } 244 + 245 + #[cfg(test)] 246 + mod tests { 247 + use super::*; 248 + 249 + fn srv(name: &str) -> ServerConfig { 250 + ServerConfig { 251 + name: name.into(), 252 + url: format!("http://{name}"), 253 + user_id: "u".into(), 254 + user_name: "user".into(), 255 + access_token: "tok".into(), 256 + device_id: "dev".into(), 257 + } 258 + } 259 + 260 + // ------------------------------------------------------------------ 261 + // derive_server_name 262 + // ------------------------------------------------------------------ 263 + 264 + #[test] 265 + fn derive_name_strips_scheme_and_path() { 266 + assert_eq!( 267 + derive_server_name("https://media.example.com:8096/path"), 268 + "media.example.com" 269 + ); 270 + assert_eq!(derive_server_name("http://192.168.1.42"), "192.168.1.42"); 271 + assert_eq!(derive_server_name("http://host:8096"), "host"); 272 + } 273 + 274 + #[test] 275 + fn derive_name_falls_back_when_input_is_empty() { 276 + assert_eq!(derive_server_name(""), "server"); 277 + assert_eq!(derive_server_name("http://"), "server"); 278 + } 279 + 280 + // ------------------------------------------------------------------ 281 + // Server management 282 + // ------------------------------------------------------------------ 283 + 284 + #[test] 285 + fn add_or_update_inserts_new_and_marks_current() { 286 + let mut cfg = Config::default(); 287 + cfg.add_or_update_server(srv("a")); 288 + assert_eq!(cfg.servers.len(), 1); 289 + assert_eq!(cfg.current_server.as_deref(), Some("a")); 290 + } 291 + 292 + #[test] 293 + fn add_or_update_overwrites_existing_by_name() { 294 + let mut cfg = Config::default(); 295 + cfg.add_or_update_server(srv("a")); 296 + let mut updated = srv("a"); 297 + updated.access_token = "new-token".into(); 298 + cfg.add_or_update_server(updated); 299 + assert_eq!(cfg.servers.len(), 1); 300 + assert_eq!(cfg.servers[0].access_token, "new-token"); 301 + } 302 + 303 + #[test] 304 + fn switch_to_moves_current_pointer() { 305 + let mut cfg = Config::default(); 306 + cfg.add_or_update_server(srv("a")); 307 + cfg.add_or_update_server(srv("b")); 308 + cfg.switch_to("a").unwrap(); 309 + assert_eq!(cfg.current_server.as_deref(), Some("a")); 310 + } 311 + 312 + #[test] 313 + fn switch_to_unknown_server_errors() { 314 + let mut cfg = Config::default(); 315 + cfg.add_or_update_server(srv("a")); 316 + assert!(cfg.switch_to("missing").is_err()); 317 + // Current unchanged. 318 + assert_eq!(cfg.current_server.as_deref(), Some("a")); 319 + } 320 + 321 + #[test] 322 + fn remove_current_falls_back_to_first_remaining() { 323 + let mut cfg = Config::default(); 324 + cfg.add_or_update_server(srv("a")); 325 + cfg.add_or_update_server(srv("b")); 326 + cfg.add_or_update_server(srv("c")); 327 + cfg.switch_to("b").unwrap(); 328 + cfg.remove_server("b").unwrap(); 329 + assert_eq!( 330 + cfg.servers.iter().map(|s| s.name.clone()).collect::<Vec<_>>(), 331 + vec!["a", "c"] 332 + ); 333 + assert_eq!(cfg.current_server.as_deref(), Some("a")); 334 + } 335 + 336 + #[test] 337 + fn remove_non_current_keeps_current_untouched() { 338 + let mut cfg = Config::default(); 339 + cfg.add_or_update_server(srv("a")); 340 + cfg.add_or_update_server(srv("b")); 341 + // add_or_update_server makes the *added* server current; assert current is b, then remove a. 342 + assert_eq!(cfg.current_server.as_deref(), Some("b")); 343 + cfg.remove_server("a").unwrap(); 344 + assert_eq!(cfg.current_server.as_deref(), Some("b")); 345 + } 346 + 347 + #[test] 348 + fn remove_last_server_clears_current() { 349 + let mut cfg = Config::default(); 350 + cfg.add_or_update_server(srv("a")); 351 + cfg.remove_server("a").unwrap(); 352 + assert!(cfg.servers.is_empty()); 353 + assert_eq!(cfg.current_server, None); 354 + } 355 + 356 + #[test] 357 + fn remove_unknown_returns_err() { 358 + let mut cfg = Config::default(); 359 + cfg.add_or_update_server(srv("a")); 360 + assert!(cfg.remove_server("missing").is_err()); 361 + } 362 + 363 + #[test] 364 + fn cycle_next_wraps_around() { 365 + let mut cfg = Config::default(); 366 + cfg.add_or_update_server(srv("a")); 367 + cfg.add_or_update_server(srv("b")); 368 + cfg.add_or_update_server(srv("c")); 369 + cfg.switch_to("a").unwrap(); 370 + assert_eq!(cfg.cycle_next().map(|s| s.name.clone()), Some("b".into())); 371 + assert_eq!(cfg.cycle_next().map(|s| s.name.clone()), Some("c".into())); 372 + // Wraps back to the first. 373 + assert_eq!(cfg.cycle_next().map(|s| s.name.clone()), Some("a".into())); 374 + } 375 + 376 + #[test] 377 + fn cycle_next_on_empty_returns_none() { 378 + let mut cfg = Config::default(); 379 + assert!(cfg.cycle_next().is_none()); 380 + } 381 + 382 + #[test] 383 + fn find_server_returns_by_exact_name() { 384 + let mut cfg = Config::default(); 385 + cfg.add_or_update_server(srv("prod")); 386 + cfg.add_or_update_server(srv("dev")); 387 + assert!(cfg.find_server("prod").is_some()); 388 + assert!(cfg.find_server("nope").is_none()); 389 + } 390 + }
+107
crates/fin-jellyfin/src/client.rs
··· 732 732 Ok(()) 733 733 } 734 734 } 735 + 736 + #[cfg(test)] 737 + mod tests { 738 + use super::*; 739 + 740 + fn item_with_container(kind: &str, container: Option<&str>) -> BaseItem { 741 + BaseItem { 742 + id: "id-1".into(), 743 + name: "n".into(), 744 + type_: kind.into(), 745 + album: None, 746 + album_id: None, 747 + album_artist: None, 748 + artists: None, 749 + series_name: None, 750 + production_year: None, 751 + run_time_ticks: None, 752 + media_type: None, 753 + container: container.map(str::to_string), 754 + index_number: None, 755 + parent_index_number: None, 756 + image_tags: None, 757 + is_folder: None, 758 + overview: None, 759 + } 760 + } 761 + 762 + // ------------------------------------------------------------------ 763 + // source_container 764 + // ------------------------------------------------------------------ 765 + 766 + #[test] 767 + fn source_container_prefers_first_entry_of_comma_list() { 768 + let it = item_with_container("Audio", Some("mp3,mpeg")); 769 + assert_eq!(source_container(&it, true), "mp3"); 770 + } 771 + 772 + #[test] 773 + fn source_container_lowercases_and_trims() { 774 + let it = item_with_container("Audio", Some(" FLAC ")); 775 + assert_eq!(source_container(&it, true), "flac"); 776 + } 777 + 778 + #[test] 779 + fn source_container_falls_back_to_mp3_or_mp4() { 780 + let audio = item_with_container("Audio", None); 781 + assert_eq!(source_container(&audio, true), "mp3"); 782 + let video = item_with_container("Movie", None); 783 + assert_eq!(source_container(&video, false), "mp4"); 784 + } 785 + 786 + #[test] 787 + fn source_container_falls_back_on_empty_string() { 788 + let it = item_with_container("Audio", Some("")); 789 + assert_eq!(source_container(&it, true), "mp3"); 790 + } 791 + 792 + // ------------------------------------------------------------------ 793 + // content_type_for_url 794 + // ------------------------------------------------------------------ 795 + 796 + #[test] 797 + fn content_type_maps_common_extensions() { 798 + assert_eq!( 799 + JellyfinClient::content_type_for_url("http://x/a/stream.mp3?foo=1"), 800 + "audio/mpeg" 801 + ); 802 + assert_eq!( 803 + JellyfinClient::content_type_for_url("http://x/a/stream.flac"), 804 + "audio/flac" 805 + ); 806 + assert_eq!( 807 + JellyfinClient::content_type_for_url("http://x/a/stream.m4a"), 808 + "audio/aac" 809 + ); 810 + assert_eq!( 811 + JellyfinClient::content_type_for_url("http://x/Videos/1/stream.mkv"), 812 + "video/x-matroska" 813 + ); 814 + assert_eq!( 815 + JellyfinClient::content_type_for_url("http://x/Videos/1/main.m3u8"), 816 + "application/vnd.apple.mpegurl" 817 + ); 818 + } 819 + 820 + #[test] 821 + fn content_type_ignores_query_string() { 822 + assert_eq!( 823 + JellyfinClient::content_type_for_url("http://x/a/stream.opus?api_key=abc&Static=true"), 824 + "audio/opus" 825 + ); 826 + } 827 + 828 + #[test] 829 + fn content_type_falls_back_by_url_shape() { 830 + // Unknown ext, but URL contains /Videos/ → video/mp4. 831 + assert_eq!( 832 + JellyfinClient::content_type_for_url("http://x/Videos/1/stream.unknown"), 833 + "video/mp4" 834 + ); 835 + // Otherwise → audio/mpeg. 836 + assert_eq!( 837 + JellyfinClient::content_type_for_url("http://x/whatever"), 838 + "audio/mpeg" 839 + ); 840 + } 841 + }
+121
crates/fin-jellyfin/src/models.rs
··· 266 266 267 267 pub type Playlist = BaseItem; 268 268 pub type PlaylistItem = BaseItem; 269 + 270 + #[cfg(test)] 271 + mod tests { 272 + use super::*; 273 + 274 + fn base() -> BaseItem { 275 + BaseItem { 276 + id: "id".into(), 277 + name: "n".into(), 278 + type_: "Audio".into(), 279 + album: None, 280 + album_id: None, 281 + album_artist: None, 282 + artists: None, 283 + series_name: None, 284 + production_year: None, 285 + run_time_ticks: None, 286 + media_type: None, 287 + container: None, 288 + index_number: None, 289 + parent_index_number: None, 290 + image_tags: None, 291 + is_folder: None, 292 + overview: None, 293 + } 294 + } 295 + 296 + #[test] 297 + fn item_kind_parses_known_variants() { 298 + assert_eq!(ItemKind::parse("Audio"), ItemKind::Audio); 299 + assert_eq!(ItemKind::parse("MusicAlbum"), ItemKind::MusicAlbum); 300 + assert_eq!(ItemKind::parse("Series"), ItemKind::Series); 301 + assert_eq!(ItemKind::parse("CollectionFolder"), ItemKind::Folder); 302 + assert_eq!(ItemKind::parse("nonsense"), ItemKind::Other); 303 + } 304 + 305 + #[test] 306 + fn item_kind_audio_and_video_classifiers() { 307 + assert!(ItemKind::Audio.is_audio()); 308 + assert!(ItemKind::MusicAlbum.is_audio()); 309 + assert!(!ItemKind::Movie.is_audio()); 310 + assert!(ItemKind::Movie.is_video()); 311 + assert!(ItemKind::Episode.is_video()); 312 + assert!(!ItemKind::Audio.is_video()); 313 + } 314 + 315 + #[test] 316 + fn item_kind_is_playable_matches_leaves_only() { 317 + assert!(ItemKind::Audio.is_playable()); 318 + assert!(ItemKind::Movie.is_playable()); 319 + assert!(ItemKind::Episode.is_playable()); 320 + // Containers are drilled-into, not directly played. 321 + assert!(!ItemKind::MusicAlbum.is_playable()); 322 + assert!(!ItemKind::Series.is_playable()); 323 + assert!(!ItemKind::Folder.is_playable()); 324 + } 325 + 326 + #[test] 327 + fn duration_secs_converts_ticks() { 328 + let mut it = base(); 329 + // 10_000_000 ticks per second in Jellyfin. 330 + it.run_time_ticks = Some(45 * 10_000_000); 331 + assert_eq!(it.duration_secs(), Some(45)); 332 + it.run_time_ticks = None; 333 + assert_eq!(it.duration_secs(), None); 334 + } 335 + 336 + #[test] 337 + fn audio_subtitle_combines_artists_and_album() { 338 + let mut it = base(); 339 + it.artists = Some(vec!["Foo".into(), "Bar".into()]); 340 + it.album = Some("The Album".into()); 341 + assert_eq!(it.subtitle(), "Foo, Bar — The Album"); 342 + } 343 + 344 + #[test] 345 + fn audio_subtitle_falls_back_to_album_artist_when_no_artists() { 346 + let mut it = base(); 347 + it.album_artist = Some("Solo".into()); 348 + it.album = Some("The Album".into()); 349 + assert_eq!(it.subtitle(), "Solo — The Album"); 350 + } 351 + 352 + #[test] 353 + fn audio_subtitle_handles_missing_fields() { 354 + let mut it = base(); 355 + it.artists = Some(vec![]); 356 + assert_eq!(it.subtitle(), ""); 357 + 358 + let mut it = base(); 359 + it.album = Some("Just Album".into()); 360 + assert_eq!(it.subtitle(), "Just Album"); 361 + } 362 + 363 + #[test] 364 + fn album_subtitle_uses_album_artist() { 365 + let mut it = base(); 366 + it.type_ = "MusicAlbum".into(); 367 + it.album_artist = Some("Grace Jones".into()); 368 + assert_eq!(it.subtitle(), "Grace Jones"); 369 + } 370 + 371 + #[test] 372 + fn movie_subtitle_is_production_year() { 373 + let mut it = base(); 374 + it.type_ = "Movie".into(); 375 + it.production_year = Some(1999); 376 + assert_eq!(it.subtitle(), "1999"); 377 + } 378 + 379 + #[test] 380 + fn episode_subtitle_formats_season_episode() { 381 + let mut it = base(); 382 + it.type_ = "Episode".into(); 383 + it.parent_index_number = Some(2); 384 + it.index_number = Some(7); 385 + it.series_name = Some("A Series".into()); 386 + // The format is "SxxExx — series name"; just check the core prefix. 387 + assert!(it.subtitle().starts_with("S02E07")); 388 + } 389 + }
+67
crates/fin-player/src/local.rs
··· 250 250 } 251 251 } 252 252 } 253 + 254 + #[cfg(test)] 255 + mod tests { 256 + use super::*; 257 + 258 + fn audio(id: &str) -> QueueItem { 259 + QueueItem { 260 + id: id.into(), 261 + title: id.into(), 262 + subtitle: String::new(), 263 + stream_url: format!("http://example/{id}.flac"), 264 + image_url: None, 265 + duration_secs: Some(120), 266 + is_video: false, 267 + content_type: "audio/flac".into(), 268 + } 269 + } 270 + 271 + fn video(id: &str) -> QueueItem { 272 + QueueItem { 273 + id: id.into(), 274 + title: id.into(), 275 + subtitle: String::new(), 276 + stream_url: format!("http://example/{id}.mp4"), 277 + image_url: None, 278 + duration_secs: Some(600), 279 + is_video: true, 280 + content_type: "video/mp4".into(), 281 + } 282 + } 283 + 284 + #[test] 285 + fn dispatch_picks_kind_of_item_at_start_index() { 286 + let items = vec![audio("a"), video("b"), audio("c")]; 287 + assert_eq!(LocalRenderer::dispatch_target(&items, 0), Active::Audio); 288 + assert_eq!(LocalRenderer::dispatch_target(&items, 1), Active::Video); 289 + assert_eq!(LocalRenderer::dispatch_target(&items, 2), Active::Audio); 290 + } 291 + 292 + #[test] 293 + fn dispatch_on_empty_or_out_of_range_returns_none() { 294 + assert_eq!(LocalRenderer::dispatch_target(&[], 0), Active::None); 295 + let items = vec![audio("a")]; 296 + assert_eq!(LocalRenderer::dispatch_target(&items, 5), Active::None); 297 + } 298 + 299 + #[test] 300 + fn filter_kind_keeps_only_matching_items() { 301 + let mixed = vec![audio("a"), video("b"), audio("c"), video("d")]; 302 + let audio_only = filter_kind(mixed.clone(), false); 303 + assert_eq!( 304 + audio_only.iter().map(|i| i.id.clone()).collect::<Vec<_>>(), 305 + vec!["a", "c"] 306 + ); 307 + let video_only = filter_kind(mixed, true); 308 + assert_eq!( 309 + video_only.iter().map(|i| i.id.clone()).collect::<Vec<_>>(), 310 + vec!["b", "d"] 311 + ); 312 + } 313 + 314 + #[test] 315 + fn filter_kind_on_empty_returns_empty() { 316 + assert!(filter_kind(vec![], false).is_empty()); 317 + assert!(filter_kind(vec![], true).is_empty()); 318 + } 319 + }
+157
crates/fin-player/src/queue.rs
··· 136 136 } 137 137 } 138 138 } 139 + 140 + #[cfg(test)] 141 + mod tests { 142 + use super::*; 143 + 144 + fn item(id: &str) -> QueueItem { 145 + QueueItem { 146 + id: id.into(), 147 + title: id.into(), 148 + subtitle: String::new(), 149 + stream_url: format!("http://example/{id}"), 150 + image_url: None, 151 + duration_secs: Some(180), 152 + is_video: false, 153 + content_type: "audio/mpeg".into(), 154 + } 155 + } 156 + 157 + fn ids(q: &PlaybackQueue) -> Vec<String> { 158 + q.items().into_iter().map(|i| i.id).collect() 159 + } 160 + 161 + #[test] 162 + fn empty_queue_has_no_current() { 163 + let q = PlaybackQueue::new(); 164 + assert!(q.is_empty()); 165 + assert_eq!(q.len(), 0); 166 + assert_eq!(q.current_index(), None); 167 + assert!(q.current().is_none()); 168 + } 169 + 170 + #[test] 171 + fn replace_sets_index_and_clamps() { 172 + let q = PlaybackQueue::new(); 173 + q.replace(vec![item("a"), item("b"), item("c")], 1); 174 + assert_eq!(q.current_index(), Some(1)); 175 + assert_eq!(q.current().unwrap().id, "b"); 176 + // Out-of-range start clamps to the last valid index. 177 + q.replace(vec![item("x"), item("y")], 99); 178 + assert_eq!(q.current_index(), Some(1)); 179 + assert_eq!(q.current().unwrap().id, "y"); 180 + } 181 + 182 + #[test] 183 + fn replace_with_empty_clears_index() { 184 + let q = PlaybackQueue::new(); 185 + q.replace(vec![item("a")], 0); 186 + q.replace(vec![], 0); 187 + assert_eq!(q.current_index(), None); 188 + assert!(q.current().is_none()); 189 + } 190 + 191 + #[test] 192 + fn append_sets_index_when_starting_empty() { 193 + let q = PlaybackQueue::new(); 194 + q.append(vec![item("a"), item("b")]); 195 + assert_eq!(q.current_index(), Some(0)); 196 + // Appending again keeps the current index. 197 + q.append(vec![item("c")]); 198 + assert_eq!(q.current_index(), Some(0)); 199 + assert_eq!(ids(&q), vec!["a", "b", "c"]); 200 + } 201 + 202 + #[test] 203 + fn insert_next_places_items_after_current() { 204 + let q = PlaybackQueue::new(); 205 + q.replace(vec![item("a"), item("b"), item("c")], 1); 206 + q.insert_next(vec![item("x"), item("y")]); 207 + assert_eq!(ids(&q), vec!["a", "b", "x", "y", "c"]); 208 + // Current stays on "b". 209 + assert_eq!(q.current().unwrap().id, "b"); 210 + } 211 + 212 + #[test] 213 + fn insert_next_into_empty_becomes_first() { 214 + let q = PlaybackQueue::new(); 215 + q.insert_next(vec![item("a"), item("b")]); 216 + assert_eq!(ids(&q), vec!["a", "b"]); 217 + assert_eq!(q.current_index(), Some(0)); 218 + } 219 + 220 + #[test] 221 + fn advance_walks_to_end_then_stops() { 222 + let q = PlaybackQueue::new(); 223 + q.replace(vec![item("a"), item("b"), item("c")], 0); 224 + assert_eq!(q.advance(), Some(1)); 225 + assert_eq!(q.advance(), Some(2)); 226 + assert_eq!(q.advance(), None); 227 + // Once past the end, current becomes None. 228 + assert_eq!(q.current_index(), None); 229 + } 230 + 231 + #[test] 232 + fn back_walks_to_zero_and_stays() { 233 + let q = PlaybackQueue::new(); 234 + q.replace(vec![item("a"), item("b"), item("c")], 2); 235 + assert_eq!(q.back(), Some(1)); 236 + assert_eq!(q.back(), Some(0)); 237 + assert_eq!(q.back(), Some(0)); 238 + } 239 + 240 + #[test] 241 + fn set_index_within_bounds_moves_cursor() { 242 + let q = PlaybackQueue::new(); 243 + q.replace(vec![item("a"), item("b"), item("c")], 0); 244 + q.set_index(2); 245 + assert_eq!(q.current().unwrap().id, "c"); 246 + // Out-of-bounds set_index is a no-op. 247 + q.set_index(99); 248 + assert_eq!(q.current().unwrap().id, "c"); 249 + } 250 + 251 + #[test] 252 + fn remove_before_current_shifts_index_down() { 253 + let q = PlaybackQueue::new(); 254 + q.replace(vec![item("a"), item("b"), item("c")], 2); 255 + q.remove(0); 256 + assert_eq!(ids(&q), vec!["b", "c"]); 257 + assert_eq!(q.current().unwrap().id, "c"); 258 + } 259 + 260 + #[test] 261 + fn remove_current_last_clamps() { 262 + let q = PlaybackQueue::new(); 263 + q.replace(vec![item("a"), item("b")], 1); 264 + q.remove(1); 265 + assert_eq!(ids(&q), vec!["a"]); 266 + assert_eq!(q.current().unwrap().id, "a"); 267 + } 268 + 269 + #[test] 270 + fn remove_only_item_clears_index() { 271 + let q = PlaybackQueue::new(); 272 + q.replace(vec![item("a")], 0); 273 + q.remove(0); 274 + assert!(q.is_empty()); 275 + assert_eq!(q.current_index(), None); 276 + } 277 + 278 + #[test] 279 + fn remove_out_of_bounds_is_noop() { 280 + let q = PlaybackQueue::new(); 281 + q.replace(vec![item("a"), item("b")], 0); 282 + q.remove(99); 283 + assert_eq!(ids(&q), vec!["a", "b"]); 284 + assert_eq!(q.current_index(), Some(0)); 285 + } 286 + 287 + #[test] 288 + fn clear_resets_everything() { 289 + let q = PlaybackQueue::new(); 290 + q.replace(vec![item("a"), item("b")], 1); 291 + q.clear(); 292 + assert!(q.is_empty()); 293 + assert_eq!(q.current_index(), None); 294 + } 295 + }
+173
crates/fin-player/src/symphonia_player.rs
··· 1114 1114 stream.play().context("start cpal stream")?; 1115 1115 Ok((stream, producer, rb, ring_capacity)) 1116 1116 } 1117 + 1118 + #[cfg(test)] 1119 + mod tests { 1120 + use super::*; 1121 + 1122 + // ------------------------------------------------------------------ 1123 + // ext_from_content_type 1124 + // ------------------------------------------------------------------ 1125 + 1126 + #[test] 1127 + fn maps_common_audio_mimes_to_extensions() { 1128 + assert_eq!(ext_from_content_type("audio/mpeg"), Some("mp3")); 1129 + assert_eq!(ext_from_content_type("audio/flac"), Some("flac")); 1130 + assert_eq!(ext_from_content_type("audio/x-flac"), Some("flac")); 1131 + assert_eq!(ext_from_content_type("audio/mp4"), Some("m4a")); 1132 + assert_eq!(ext_from_content_type("audio/ogg"), Some("ogg")); 1133 + assert_eq!(ext_from_content_type("audio/opus"), Some("opus")); 1134 + assert_eq!(ext_from_content_type("audio/wav"), Some("wav")); 1135 + } 1136 + 1137 + #[test] 1138 + fn ignores_content_type_parameters_and_case() { 1139 + assert_eq!( 1140 + ext_from_content_type("audio/MPEG; charset=binary"), 1141 + Some("mp3") 1142 + ); 1143 + assert_eq!(ext_from_content_type(" Audio/FLAC "), Some("flac")); 1144 + } 1145 + 1146 + #[test] 1147 + fn unknown_mime_returns_none() { 1148 + assert_eq!(ext_from_content_type("video/mp4"), None); 1149 + assert_eq!(ext_from_content_type(""), None); 1150 + } 1151 + 1152 + // ------------------------------------------------------------------ 1153 + // Resampler::convert_channels 1154 + // ------------------------------------------------------------------ 1155 + 1156 + #[test] 1157 + fn channel_conversion_is_a_noop_for_matching_layouts() { 1158 + let r = Resampler::new(48_000, 48_000, 2, 2); 1159 + let input = vec![1.0, 2.0, 3.0, 4.0]; 1160 + assert_eq!(r.convert_channels(&input), input); 1161 + } 1162 + 1163 + #[test] 1164 + fn stereo_downmixes_to_mono_as_average() { 1165 + let r = Resampler::new(48_000, 48_000, 2, 1); 1166 + // Two frames: (L=1.0, R=3.0), (L=-1.0, R=1.0). 1167 + let input = vec![1.0, 3.0, -1.0, 1.0]; 1168 + let out = r.convert_channels(&input); 1169 + assert_eq!(out, vec![2.0, 0.0]); 1170 + } 1171 + 1172 + #[test] 1173 + fn mono_broadcasts_to_every_output_channel() { 1174 + let r = Resampler::new(48_000, 48_000, 1, 2); 1175 + let input = vec![0.5, -0.25]; 1176 + assert_eq!(r.convert_channels(&input), vec![0.5, 0.5, -0.25, -0.25]); 1177 + } 1178 + 1179 + #[test] 1180 + fn extra_output_channels_get_duplicated_front_pair() { 1181 + // Stereo → 5.1 layout: c=0/1 pass through, c>=2 fall back to L/R pattern. 1182 + let r = Resampler::new(48_000, 48_000, 2, 6); 1183 + let input = vec![1.0, 2.0]; // one frame: L=1, R=2 1184 + let out = r.convert_channels(&input); 1185 + // 6 channels: L, R, then alternating L/R fill. 1186 + assert_eq!(out, vec![1.0, 2.0, 1.0, 2.0, 1.0, 2.0]); 1187 + } 1188 + 1189 + // ------------------------------------------------------------------ 1190 + // Resampler::resample 1191 + // ------------------------------------------------------------------ 1192 + 1193 + #[test] 1194 + fn resample_is_a_noop_when_rates_match() { 1195 + let mut r = Resampler::new(44_100, 44_100, 2, 2); 1196 + let input: Vec<f32> = (0..20).map(|i| i as f32).collect(); 1197 + let out = r.resample(input.clone()); 1198 + assert_eq!(out, input); 1199 + } 1200 + 1201 + #[test] 1202 + fn downsample_48k_to_44_1k_produces_expected_frame_count() { 1203 + // 48 kHz → 44.1 kHz means output_frames ≈ input_frames * 44100/48000. 1204 + // The resampler only emits frames it can fully interpolate, so with a 1205 + // single packet we get floor rather than round — but close enough. 1206 + let mut r = Resampler::new(48_000, 44_100, 2, 2); 1207 + let input_frames = 48_000; // one second of 48 kHz 1208 + let input = vec![0.5f32; input_frames * 2]; 1209 + let out = r.resample(input); 1210 + let out_frames = out.len() / 2; 1211 + // One second at 44.1 kHz = 44100 frames. Allow ±2 for boundary rounding. 1212 + assert!( 1213 + (out_frames as i64 - 44_100).abs() <= 2, 1214 + "downsample produced {} frames, expected ~44100", 1215 + out_frames 1216 + ); 1217 + } 1218 + 1219 + #[test] 1220 + fn upsample_44_1k_to_48k_produces_expected_frame_count() { 1221 + let mut r = Resampler::new(44_100, 48_000, 2, 2); 1222 + let input_frames = 44_100; 1223 + let input = vec![0.5f32; input_frames * 2]; 1224 + let out = r.resample(input); 1225 + let out_frames = out.len() / 2; 1226 + assert!( 1227 + (out_frames as i64 - 48_000).abs() <= 2, 1228 + "upsample produced {} frames, expected ~48000", 1229 + out_frames 1230 + ); 1231 + } 1232 + 1233 + #[test] 1234 + fn cross_packet_output_matches_single_packet_output() { 1235 + // Same total input, delivered as one packet vs two halves, should 1236 + // yield essentially the same output stream. Boundary interpolation 1237 + // uses `last_frame`, so results converge within a couple of samples. 1238 + let sr_in = 48_000; 1239 + let sr_out = 44_100; 1240 + let n_frames = 4_800; // 100 ms 1241 + let input: Vec<f32> = (0..n_frames * 2).map(|i| (i as f32).sin()).collect(); 1242 + 1243 + let mut r1 = Resampler::new(sr_in, sr_out, 2, 2); 1244 + let single = r1.resample(input.clone()); 1245 + 1246 + let mut r2 = Resampler::new(sr_in, sr_out, 2, 2); 1247 + let (a, b) = input.split_at(n_frames); // half the frames = n_frames/2 samples each 1248 + let half1 = r2.resample(a.to_vec()); 1249 + let half2 = r2.resample(b.to_vec()); 1250 + let combined: Vec<f32> = half1.into_iter().chain(half2).collect(); 1251 + 1252 + // Both paths should produce close-to-identical frame counts. 1253 + let diff = (single.len() as i64 - combined.len() as i64).abs(); 1254 + assert!( 1255 + diff <= 4, 1256 + "single-packet and split-packet output differ by {} samples", 1257 + diff 1258 + ); 1259 + } 1260 + 1261 + #[test] 1262 + fn resampler_saves_last_frame_across_calls_at_matching_rates() { 1263 + // Even in the passthrough path we save last_frame so a subsequent 1264 + // rate change would interpolate cleanly. Verifies the have_last 1265 + // flag is set after any non-empty process(). 1266 + let mut r = Resampler::new(48_000, 48_000, 2, 2); 1267 + let _ = r.resample(vec![0.0, 1.0, 2.0, 3.0]); 1268 + assert!(r.have_last); 1269 + assert_eq!(r.last_frame, vec![2.0, 3.0]); 1270 + } 1271 + 1272 + // ------------------------------------------------------------------ 1273 + // Resampler::process (end-to-end: channel conv + rate conv) 1274 + // ------------------------------------------------------------------ 1275 + 1276 + #[test] 1277 + fn process_downmixes_and_resamples_together() { 1278 + let mut r = Resampler::new(48_000, 44_100, 2, 1); 1279 + // 480 stereo frames at 48 kHz = 10 ms. After downmix + downsample we 1280 + // expect ~441 mono frames. 1281 + let input: Vec<f32> = (0..480).flat_map(|i| [i as f32, -(i as f32)]).collect(); 1282 + let out = r.process(&input); 1283 + assert!( 1284 + (out.len() as i64 - 441).abs() <= 2, 1285 + "process yielded {} samples, expected ~441", 1286 + out.len() 1287 + ); 1288 + } 1289 + }