use super::*; use clap::Parser; use ratatui::crossterm::event::KeyModifiers; use std::{ fs, io::Write, sync::atomic::{AtomicU64, Ordering}, }; use tubular::miruro::Detail; struct Directory(PathBuf); impl Directory { fn new() -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); let directory = std::env::temp_dir().join(format!( "tubular-miruro-ui-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); fs::create_dir(&directory).unwrap(); Self(directory) } fn browser(&self, query: Option<&str>) -> Browser { let path = self.0.join("config.toml"); fs::write(&path, "").unwrap(); let options = Options { player: self.0.join("missing-player").into_os_string(), default_config: false, miruro: true, anilist: false, query: query.map(str::to_owned), }; Browser::new( options, path.clone(), config::Config::default(), State::open(&path).unwrap(), ) } } impl Drop for Directory { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.0); } } fn media(id: &str) -> Media { miruro_browser::catalog(vec![serde_json::json!({"id":id, "title":{"english":format!("Series {id}")}, "episode_count":12, "description":"Series synopsis", "episode_counts":{"sub":null}})]).unwrap().remove(0) } fn series(media: &Media) -> miruro_browser::Series { let mut metadata = media.metadata.clone(); metadata.as_object_mut().unwrap().remove("episode_count"); miruro_browser::series(&media.id, Detail { media: metadata, episodes: vec![ serde_json::json!({"episode_number":3, "absolute_number":49, "title":"Actual episode", "synopsis":"Episode synopsis", "aired_on":"2026-10-02", "duration_seconds":1500}), serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}), ] }).unwrap() } fn catalog_results(browser: &mut Browser) { browser.accept( browser.generation, Ok(Response::Catalog(vec![media("first"), media("second")])), ); } fn enter_series(browser: &mut Browser) { browser.open(); let Content::Series { media, .. } = &browser.screen().content else { panic!("series screen"); }; let result = series(media); browser.accept(browser.generation, Ok(Response::Series(result))); } #[test] fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() { use ratatui::{Terminal, backend::TestBackend}; let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); browser .screen_mut() .overview .push_str(&"\nMore synopsis".repeat(40)); browser.screen_mut().overview.push_str("\nTAIL"); browser.screen_mut().update_description(); let selected = browser.screen().selected_video().unwrap().id; for (width, height) in [(120, 40), (60, 24), (24, 12)] { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::Info); browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::Description); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); let popup = crate::preview_area(terminal.backend().buffer().area); let popup_text = |terminal: &Terminal| -> String { (popup.y + 1..popup.bottom() - 1) .flat_map(|y| { (popup.x + 1..popup.right() - 1) .map(move |x| terminal.backend().buffer()[(x, y)].symbol()) }) .collect() }; assert!(popup_text(&terminal).contains("Episode 3")); browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); assert_eq!(browser.screen().description_scroll, 1); assert_eq!(browser.screen().selected_video().unwrap().id, selected); browser.screen_mut().description_scroll = u16::MAX; terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); assert!(popup_text(&terminal).contains("TAIL")); browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::Info); browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(browser.preview.is_none()); assert_eq!(browser.screen().selected_video().unwrap().id, selected); browser.screen_mut().description_scroll = 0; } browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); assert_eq!( browser.screen().selected_video().unwrap().id, "miruro:first:7" ); } #[test] fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() { let directory = Directory::new(); let mut browser = directory.browser(Some("apothecary")); assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "apothecary")); catalog_results(&mut browser); browser.select(Action::VideoNext); enter_series(&mut browser); assert_eq!(browser.screen().len(), 2); // Neither planned 12 nor absolute 49 creates rows. assert!( matches!(&browser.screen().content, Content::Series { media, .. } if media.metadata["episode_count"] == 12 && media.metadata["episode_counts"]["sub"].is_null()) ); assert_eq!( browser.screen().selected_video().unwrap().id, "miruro:second:3" ); browser.select(Action::VideoNext); browser.save(CollectionKind::Saved); browser.history(false); assert_eq!(browser.screen().selection.selected(), Some(1)); assert!( matches!(&browser.screen().content, Content::Catalog { query, media, .. } if query == "apothecary" && media.len() == 2) ); assert!(browser.pending.is_none()); browser.history(true); assert_eq!( browser.screen().selected_video().unwrap().id, "miruro:second:7" ); assert!(browser.pending.is_none()); assert!( browser .state .contains(CollectionKind::Saved, "miruro:second:7") ); } #[test] fn latest_intent_and_navigation_reject_stale_catalog_and_series() { let directory = Directory::new(); let mut browser = directory.browser(Some("old")); let old = browser.generation; browser.edit_search(); browser.input = Some("latest".into()); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "latest")); browser.accept(old, Ok(Response::Catalog(vec![media("stale")]))); assert_eq!(browser.screen().len(), 0); catalog_results(&mut browser); browser.open(); let stale = browser.generation; let Content::Series { media, .. } = &browser.screen().content else { panic!("series"); }; let data = series(media); browser.history(false); browser.accept(stale, Ok(Response::Series(data))); assert!(matches!(browser.screen().content, Content::Catalog { .. })); browser.history(true); let Content::Series { media, .. } = &browser.screen().content else { panic!("series"); }; let data = series(media); browser.accept(browser.generation, Ok(Response::Series(data))); assert_eq!( browser.screen().selected_video().unwrap().id, "miruro:first:3" ); } #[test] fn manual_watch_status_refreshes_filters_and_preserves_other_providers() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)); assert!(browser.state.videos(CollectionKind::Watching).is_empty()); assert!(!browser.bindings().iter().any(|(action, _)| { matches!( action, Action::WatchStatusWatching | Action::WatchStatusWatched ) })); enter_series(&mut browser); let episode = browser.screen().selected_video().unwrap(); let mut youtube = episode.clone(); youtube.id = "jNQXAC9IVRw".into(); youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into()); youtube.uploader_id = Some("@jawed".into()); browser .state .save(CollectionKind::Watching, &youtube) .unwrap(); browser.save(CollectionKind::WatchLater); browser.preview = Some(PreviewMode::Info); browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)); assert!( browser .state .contains(CollectionKind::Watching, &episode.id) ); assert!(!browser.state.is_watched(&episode.id)); browser.preview = None; browser.collection(CollectionKind::Watching); browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE)); assert!(browser.state.is_watched(&episode.id)); assert_eq!(browser.screen().len(), 0); browser.collection(CollectionKind::WatchLater); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 0); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 1); browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 0); assert!(!browser.state.is_watched(&episode.id)); assert!( browser .state .contains(CollectionKind::Watching, &episode.id) ); assert!( browser .state .contains(CollectionKind::Watching, &youtube.id) ); assert!(!browser.state.is_watched(&youtube.id)); let state = State::open(&browser.config_path).unwrap(); assert!(state.contains(CollectionKind::Watching, &episode.id)); assert!(state.contains(CollectionKind::WatchLater, &episode.id)); let overrides: toml_edit::DocumentMut = "[keys.video.status]\nwatched = 'alt+m'\n".parse().unwrap(); browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap(); browser.collection(CollectionKind::Watching); browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE)); assert!(!browser.state.is_watched(&episode.id)); browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT)); assert!(browser.state.is_watched(&episode.id)); assert!(state.contains(CollectionKind::Watching, &youtube.id)); } #[test] fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); let episode = browser.screen().selected_video().unwrap(); let mut youtube = episode.clone(); youtube.id = "jNQXAC9IVRw".into(); youtube.title = "YouTube title".into(); youtube.channel = Some("jawed".into()); youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into()); youtube.uploader_id = Some("@jawed".into()); for kind in [ CollectionKind::Saved, CollectionKind::WatchLater, CollectionKind::Watching, ] { browser.state.save(kind, &episode).unwrap(); browser.state.save(kind, &youtube).unwrap(); } browser.collection(CollectionKind::Saved); assert_eq!(browser.screen().len(), 1); browser.remove(true); assert_eq!( browser.removal.as_ref().unwrap().1.as_slice(), std::slice::from_ref(&episode.id) ); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(browser.state.contains(CollectionKind::Saved, &youtube.id)); assert!(!browser.state.contains(CollectionKind::Saved, &episode.id)); assert!( browser .state .contains(CollectionKind::WatchLater, &episode.id) ); let report = browser.state.progress_file(&episode).unwrap(); crate::video_state::register_identity(&report, &episode.id).unwrap(); let mut output = fs::OpenOptions::new(); output.write(true).create_new(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; output.mode(0o600); } let mut output = output.open(report).unwrap(); writeln!(output, "{}", serde_json::json!({"version":2,"event":"ended","media_identity":Identity::from_key(&episode.id).unwrap(),"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true})).unwrap(); assert!(browser.state.poll_progress().unwrap()); browser.rebuild_collections(); assert!(browser.state.is_watched(&episode.id)); assert!( !browser .state .contains(CollectionKind::Watching, &episode.id) ); for kind in [CollectionKind::Watching, CollectionKind::WatchLater] { browser.collection(kind); if matches!(kind, CollectionKind::WatchLater) { browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 0); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 1); browser.remove(true); browser.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); assert!(browser.state.is_watched(&episode.id)); } assert!(browser.state.contains(kind, &youtube.id)); } let state = State::open(&browser.config_path).unwrap(); assert!(state.contains(CollectionKind::Saved, &youtube.id)); assert!(state.contains(CollectionKind::Watching, &youtube.id)); } #[test] fn launcher_failure_does_not_mark_series_or_offline_episodes_as_started() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); browser.select(Action::VideoNext); let selected = browser.screen().selected_video().unwrap(); assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7); browser.open(); assert!(browser.screen().error.is_some()); assert!(!browser.state.is_watched(&selected.id)); assert!( !browser .state .contains(CollectionKind::Watching, &selected.id) ); browser.save(CollectionKind::Saved); browser.collection(CollectionKind::Saved); browser.open(); assert!(browser.screen().error.is_some()); assert!(!browser.state.is_watched(&selected.id)); assert!( !browser .state .contains(CollectionKind::Watching, &selected.id) ); assert!(browser.state.contains(CollectionKind::Saved, &selected.id)); } #[test] fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); let overrides: toml_edit::DocumentMut = "[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n" .parse() .unwrap(); browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap(); browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::TagList); browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::TagInput); browser.paste("Safe"); browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE)); browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); assert_eq!(browser.context(), Context::TagList); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(browser.state.shows().is_empty()); assert_eq!(browser.index, 0); browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE)); assert_eq!( browser.state.shows()[0].tags, BTreeSet::from(["Safe".into()]) ); assert!(browser.modal.is_none()); } #[test] fn checked_show_categories_survive_hover_and_filter_changes() { let directory = Directory::new(); let mut browser = directory.browser(None); for (id, tag) in [("first", "Drama"), ("second", "Favorites")] { browser .state .save_show(&media(id), &[tag.into()].into()) .unwrap(); } for filter in [None, Some("no matching category")] { browser.category_picker(); for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] { browser.key(KeyEvent::new(code, KeyModifiers::NONE)); } if let Some(filter) = filter { browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); browser.paste(filter); } browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let Content::Library { shows, .. } = &browser.screen().content else { panic!("Checked categories must open even without a hovered match"); }; assert_eq!( shows .iter() .map(|show| show.media.id.as_str()) .collect::>(), ["first"] ); } } #[test] fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); let key = |browser: &mut Browser, code| { browser.key(KeyEvent::new(code, KeyModifiers::NONE)); }; key(&mut browser, KeyCode::Char('s')); key(&mut browser, KeyCode::Char('n')); browser.paste("Drama"); key(&mut browser, KeyCode::Enter); key(&mut browser, KeyCode::Esc); assert!(browser.state.shows().is_empty()); key(&mut browser, KeyCode::Char('s')); key(&mut browser, KeyCode::Enter); assert!(browser.state.shows()[0].tags.is_empty()); enter_series(&mut browser); browser.select(Action::VideoNext); browser.save(CollectionKind::Saved); let episode = browser.screen().selected_video().unwrap().id; key(&mut browser, KeyCode::Char('s')); for tag in ["Drama", "Favorites"] { key(&mut browser, KeyCode::Char('n')); browser.paste(tag); key(&mut browser, KeyCode::Enter); } key(&mut browser, KeyCode::Enter); assert_eq!(browser.state.shows()[0].media.id, "first"); browser .state .save_show(&media("second"), &BTreeSet::from(["Drama".into()])) .unwrap(); assert_eq!( library_shows( &browser.state, &BTreeSet::from(["Drama".into(), "Favorites".into()]) ) .len(), 2 ); browser.state = State::open(&browser.config_path).unwrap(); key(&mut browser, KeyCode::Char('c')); key(&mut browser, KeyCode::Char('i')); browser.paste("Favorites"); key(&mut browser, KeyCode::Enter); assert_eq!(browser.screen().len(), 1); browser.open(); assert!( matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first") ); browser.history(false); browser.library(BTreeSet::new()); key(&mut browser, KeyCode::Char('x')); assert_eq!(browser.screen().len(), 1); browser.history(false); assert_eq!(browser.screen().len(), 0); assert!(browser.state.contains(CollectionKind::Saved, &episode)); assert_eq!( State::open(&browser.config_path).unwrap().shows()[0] .media .id, "second" ); } #[test] fn empty_errors_and_query_controls_do_not_create_results_or_commands() { let directory = Directory::new(); let mut browser = directory.browser(None); assert!(browser.pending.is_none()); browser.open(); assert_eq!(browser.index, 0); browser.edit_search(); browser.paste("Anime\nquery\t\u{1b}[31m"); browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE)); assert!( browser .input .as_ref() .unwrap() .chars() .all(|ch| !ch.is_control()) ); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let old = browser.generation; browser.accept(old, Err(io::Error::other("Catalog request failed"))); assert!(browser.screen().error.is_some()); assert_eq!(browser.screen().len(), 0); browser.search(); browser.accept(browser.generation, Ok(Response::Catalog(Vec::new()))); assert!(browser.screen().error.is_none()); assert!(matches!( browser.screen().content, Content::Catalog { searched: true, .. } )); browser.edit_search(); browser.input = Some(" ".into()); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(browser.pending.is_none()); assert!(matches!( browser.screen().content, Content::Catalog { searched: false, .. } )); browser.accept(old, Ok(Response::Catalog(vec![media("stale")]))); assert_eq!(browser.screen().len(), 0); assert!(browser.screen().rows.is_empty()); fs::write( &browser.config_path, "[keys]\nsearch.edit = 'f8'\napp.quit = 'f9'\n", ) .unwrap(); browser.config = config::load(&browser.config_path).unwrap(); browser.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); assert!(browser.input.is_some()); browser.input = Some("safe".into()); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let metadata = miruro_browser::catalog(vec![serde_json::json!({ "id":"safe", "title":{"english":"薬屋 Café\u{1b}[31m"}, "description":"

First\u{1b}[31m

Second
Third

" })]) .unwrap(); browser.accept(browser.generation, Ok(Response::Catalog(metadata))); assert!( browser .screen() .rows .iter() .all(|row| row.title.chars().all(|ch| !ch.is_control())) ); assert!( browser .screen() .description .chars() .all(|ch| !ch.is_control() || ch == '\n') ); assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE))); assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE))); } #[test] fn episode_checks_preserve_check_order_and_cached_screen_history() { use ratatui::{Terminal, backend::TestBackend}; let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); browser.select(Action::VideoLast); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.select(Action::VideoFirst); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let ids = |browser: &Browser| { browser .screen() .playback_videos() .into_iter() .map(|video| video.id) .collect::>() }; assert_eq!(ids(&browser), ["miruro:first:7", "miruro:first:3"]); assert!(browser.hints().contains("play checked")); assert!(browser.hints().contains("clear checks")); let mut terminal = Terminal::new(TestBackend::new(120, 32)).unwrap(); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); let text: String = terminal .backend() .buffer() .content .iter() .map(|cell| cell.symbol()) .collect(); assert!(text.contains("2 checked")); browser.select(Action::VideoLast); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(ids(&browser), ["miruro:first:3"]); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]); if let Content::Series { data: Some(data), .. } = &mut browser.screen_mut().content { data.reverse(); } browser.screen_mut().rebuild_display(); assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]); browser.history(false); assert!(browser.screen().checked.is_empty()); browser.history(true); assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]); browser.screen_mut().selection.select(None); assert_eq!(ids(&browser), ["miruro:first:3", "miruro:first:7"]); browser.select(Action::VideoLast); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL)); assert!(browser.screen().checked.is_empty()); assert_eq!(ids(&browser), ["miruro:first:3"]); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.refresh(); assert!(browser.screen().checked.is_empty()); // Checks made against old metadata while loading must not survive replacement. browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let replacement = series(browser.screen().selected_media().unwrap()); browser.accept(browser.generation, Ok(Response::Series(replacement))); assert!(browser.screen().checked.is_empty()); assert_eq!(ids(&browser), ["miruro:first:3"]); } #[test] fn catalog_and_saved_show_rows_cannot_be_checked() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let generation = browser.generation; browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); assert_eq!(browser.generation, generation); assert!(matches!(browser.screen().content, Content::Catalog { .. })); assert!(browser.player.is_none()); assert!(browser.screen().checked.is_empty()); assert!(browser.screen().playback_videos().is_empty()); assert!(!browser.bindings().iter().any(|(action, _)| { matches!( action, Action::VideoToggle | Action::VideoClear | Action::VideoEnqueue ) })); browser .state .save_show(&media("first"), &BTreeSet::new()) .unwrap(); browser.library(BTreeSet::new()); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); let generation = browser.generation; browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SUPER)); assert_eq!(browser.generation, generation); assert!(matches!(browser.screen().content, Content::Library { .. })); assert!(browser.player.is_none()); assert!(browser.screen().checked.is_empty()); assert!(browser.screen().playback_videos().is_empty()); enter_series(&mut browser); assert_eq!(browser.screen().len(), 2); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(browser.screen().checked, ["miruro:first:3"]); browser.edit_search(); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(browser.input.as_deref(), Some(" ")); assert!(browser.screen().checked.is_empty()); browser.input = None; browser.history(false); assert_eq!(browser.screen().checked, ["miruro:first:3"]); } #[test] fn collection_checks_prune_hidden_and_removed_episodes_without_resurrection() { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); let first = browser.screen().selected_video().unwrap(); browser.save(CollectionKind::WatchLater); browser.select(Action::VideoNext); let second = browser.screen().selected_video().unwrap(); browser.save(CollectionKind::WatchLater); browser .state .set_watch_status(&first, WatchStatus::Watched) .unwrap(); browser.collection(CollectionKind::WatchLater); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.select(Action::VideoNext); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); assert_eq!(browser.screen().checked.len(), 2); browser.rebuild_collections(); assert_eq!(browser.screen().checked.len(), 2); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().checked, [second.id.clone()]); assert_eq!(browser.screen().playback_videos()[0].id, second.id); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert!(browser.screen().checked.is_empty()); assert_eq!(browser.screen().selected_video().unwrap().id, first.id); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.remove(false); assert!(browser.screen().checked.is_empty()); assert!(browser.screen().playback_videos().is_empty()); browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); assert_eq!(browser.screen().len(), 1); assert!(browser.screen().checked.is_empty()); assert_eq!(browser.screen().playback_videos()[0].id, second.id); } #[cfg(unix)] #[test] fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() { for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { let directory = Directory::new(); let mut browser = directory.browser(Some("anime")); catalog_results(&mut browser); enter_series(&mut browser); let first = browser.screen().selected_video().unwrap(); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.select(Action::VideoNext); let hovered = browser.screen().selected_video().unwrap(); browser.save(CollectionKind::Saved); browser.set_watch_status(WatchStatus::Watching); assert!(!browser.state.contains(CollectionKind::Saved, &first.id)); assert!(!browser.state.contains(CollectionKind::Watching, &first.id)); assert!(browser.state.contains(CollectionKind::Saved, &hovered.id)); assert!( browser .state .contains(CollectionKind::Watching, &hovered.id) ); browser.key(KeyEvent::new(KeyCode::Enter, modifiers)); assert!(browser.screen().error.is_some()); assert_eq!(browser.screen().checked, [first.id.clone()]); assert_eq!(browser.screen().playback_videos()[0].id, first.id); browser.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); browser.options.player = "/usr/bin/true".into(); browser.key(KeyEvent::new(KeyCode::Enter, modifiers)); assert!(browser.screen().error.is_none()); assert!(browser.player.is_some()); assert!(browser.screen().checked.is_empty()); assert_eq!(browser.screen().playback_videos()[0].id, hovered.id); } } #[test] fn anilist_search_results_open_without_waiting_for_another_catalog_request() { let directory = Directory::new(); let path = directory.0.join("config.toml"); fs::write(&path, "").unwrap(); let mut browser = Browser::new( Options::parse_from(["tubular", "--anilist", "Cowboy Bebop"]), path.clone(), config::Config::default(), State::open(&path).unwrap(), ); let media = miruro_browser::catalog(vec![serde_json::json!({ "id":"1", "catalog_provider":"anilist", "title":{"romaji":"Cowboy Bebop"}, "status":"FINISHED", "episode_count":26, "catalog_count":26, "description":"Search synopsis" })]) .unwrap(); browser.accept(browser.generation, Ok(Response::Catalog(media))); browser.pending = None; browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( browser.screen().selected_video().map(|video| video.id), Some("anilist:1:1".into()), "opening a complete search result must expose episodes immediately" ); assert!(browser.pending.is_none()); assert!(browser.screen().overview.contains("Search synopsis")); browser.select(Action::VideoLast); assert_eq!( browser.screen().selected_video().unwrap().id, "anilist:1:26" ); browser.refresh(); assert!( matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "1") ); let refreshed = miruro_browser::anilist_series(serde_json::json!({ "id":"1", "catalog_provider":"anilist", "title":{"romaji":"Cowboy Bebop"}, "status":"FINISHED", "episode_count":27 })) .unwrap(); browser.accept(browser.generation, Ok(Response::Series(refreshed))); assert_eq!(browser.screen().len(), 27); } #[test] fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() { let directory = Directory::new(); let path = directory.0.join("config.toml"); let options = Options::parse_from(["tubular", "--anilist"]); let mut browser = Browser::new( options, path.clone(), config::Config::default(), State::open(&path).unwrap(), ); let series = miruro_browser::anilist_series(serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Unknown total"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap(); browser.push(Screen::new(Content::Series { media: series.media, data: Some(series.episodes), })); browser.edit_search(); assert!(browser.episode_input); browser.input = Some("7".into()); browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let selected = browser.screen().selected_video().unwrap(); assert_eq!(selected.id, "anilist:1:7"); browser.save(CollectionKind::Saved); let mut legacy = selected.clone(); legacy.id = "miruro:1:7".into(); legacy.channel = Some("Miruro".into()); browser.state.save(CollectionKind::Saved, &legacy).unwrap(); browser.collection(CollectionKind::Saved); assert_eq!(browser.screen().len(), 1); assert_eq!(browser.screen().selected_video().unwrap().id, selected.id); browser.history(false); for invalid in ["0", "01", "-1", "2147483648"] { browser.select_episode(invalid); assert!(browser.screen().error.is_some()); assert_eq!(browser.screen().len(), 1); } browser.select_episode("8"); assert!(browser.screen().error.is_none()); browser.screen_mut().toggle_checked(); browser.select_episode("7"); browser.screen_mut().toggle_checked(); let queue = browser.screen().playback_videos(); assert_eq!( queue .iter() .map(|video| video.id.as_str()) .collect::>(), ["anilist:1:8", "anilist:1:7"] ); assert_eq!( crate::youtube::video_url(&queue[0].id).unwrap(), "https://zokoanime.video/stream/ani/1/8/sub" ); browser.key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); assert!(matches!(browser.screen().content, Content::Catalog { .. })); assert!(!browser.episode_input); } fn anilist_browser(directory: &Directory, count: u32) -> Browser { let mut browser = directory.browser(None); browser.provider = Provider::AniList; browser.options.anilist = true; browser.options.miruro = false; browser.state.select_catalog(Provider::AniList); let series = miruro_browser::anilist_series(serde_json::json!({ "id":"1", "catalog_provider":"anilist", "title":{"romaji":"Dragon Ball"}, "status":"FINISHED", "episode_count":count, "catalog_count":count, "episode_duration_minutes":25, "format":"TV", "season_year":1986 })) .unwrap(); browser.push(Screen::new(Content::Series { media: series.media, data: Some(series.episodes), })); browser } #[test] fn source_candidates_use_rendered_offset_and_three_line_viewport_not_series_size() { use ratatui::{Terminal, backend::TestBackend}; let directory = Directory::new(); let mut browser = anilist_browser(&directory, 153); let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); let initial = browser.source_visible.clone(); assert!(initial.len() < 153); assert_eq!(initial.first().unwrap(), "anilist:1:1"); let [_, _, content, _, _] = ui::browser_layout(terminal.backend().buffer().area, 1); assert_eq!( initial.len(), usize::from(content.height.saturating_sub(2)) / 3 ); browser.select(Action::VideoLast); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); let offset = browser.screen().selection.offset(); assert!(offset > 0); assert_eq!( browser.source_visible.first().unwrap(), &format!("anilist:1:{}", offset + 1) ); assert!(browser.source_visible.contains(&"anilist:1:153".into())); assert!( !browser .source_visible .iter() .any(|key| initial.contains(key)) ); let selection = browser.screen().selected_key().unwrap().to_owned(); let now = Instant::now(); // Inject one fixed clock for every scheduler transition in this test. browser.update_source_checks(now); assert_eq!( browser.source_checks.status(&selection), Some(source_check::Status::Failed) ); assert_eq!(browser.screen().selected_key(), Some(selection.as_str())); browser.update_source_checks(now); assert_eq!(browser.screen().selected_key(), Some(selection.as_str())); assert_eq!(browser.screen().len(), 153); } #[test] fn source_eligibility_pauses_under_popups_loading_and_catalogs_then_resumes_from_frame() { let directory = Directory::new(); let mut browser = anilist_browser(&directory, 10); let candidates = |browser: &mut Browser| { browser.update_visible_sources(8); browser.source_visible.clone() }; assert_eq!(candidates(&mut browser), ["anilist:1:1", "anilist:1:2"]); browser.help = Some(0); assert!(candidates(&mut browser).is_empty()); browser.help = None; browser.preview = Some(PreviewMode::Info); assert!(candidates(&mut browser).is_empty()); browser.preview = None; browser.input = Some(String::new()); assert!(candidates(&mut browser).is_empty()); browser.input = None; browser.removal = Some((CollectionKind::Saved, vec![])); assert!(candidates(&mut browser).is_empty()); browser.removal = None; browser.modal = Some(LibraryModal::Categories { names: vec![], checked: BTreeSet::new(), picker: Picker::default(), }); assert!(candidates(&mut browser).is_empty()); browser.modal = None; browser.request( Request::Episodes { media_id: "1".into(), slug: String::new(), }, Intent::Series("1".into()), ); assert!(candidates(&mut browser).is_empty()); browser.invalidate(); assert!(browser.source_visible.is_empty()); assert_eq!(candidates(&mut browser).len(), 2); browser.history(false); assert!(candidates(&mut browser).is_empty()); browser.history(true); assert_eq!(candidates(&mut browser).len(), 2); browser.provider = Provider::Miruro; assert!(candidates(&mut browser).is_empty()); } #[test] fn source_checks_are_session_only_and_collection_rows_keep_selection_checks_and_watch_state() { use ratatui::{Terminal, backend::TestBackend}; let directory = Directory::new(); let mut browser = anilist_browser(&directory, 10); let video = browser.screen().selected_video().unwrap(); browser.save(CollectionKind::Saved); browser.set_watch_status(WatchStatus::Watched); browser.collection(CollectionKind::Saved); browser.screen_mut().toggle_checked(); let checks = browser.screen().checked.clone(); let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap(); let watch = fs::read(directory.0.join("watch-state.toml")).unwrap(); let config = fs::read(&browser.config_path).unwrap(); let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); assert_eq!( browser.source_visible.as_slice(), std::slice::from_ref(&video.id) ); assert_eq!(browser.rows()[0].height(), 3); let now = Instant::now(); // Fixed injected clock; no elapsed-time dependency. browser.update_source_checks(now); assert_eq!( browser.source_checks.status(&video.id), Some(source_check::Status::Failed) ); assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); assert_eq!(browser.screen().checked, checks); assert!(browser.state.is_watched(&video.id)); assert_eq!( fs::read(directory.0.join("saved-videos.toml")).unwrap(), saved ); assert_eq!( fs::read(directory.0.join("watch-state.toml")).unwrap(), watch ); assert_eq!(fs::read(&browser.config_path).unwrap(), config); browser.refresh(); assert_eq!(browser.source_checks.status(&video.id), None); assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); browser.provider = Provider::Miruro; assert_eq!(browser.rows()[0].height(), 2); } #[test] fn episode_three_lines_align_after_shared_marker_and_leave_metadata_second() { use ratatui::{Terminal, backend::TestBackend}; let directory = Directory::new(); let mut browser = anilist_browser(&directory, 2); let video = browser.screen().selected_video().unwrap(); browser.set_watch_status(WatchStatus::Watched); let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap(); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); let buffer = terminal.backend().buffer(); let find = |needle: &str| { (0..buffer.area.height) .find_map(|y| { let line = (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol()) .collect::(); line.find(needle) .map(|byte| (line[..byte].chars().count(), y)) }) .unwrap() }; let title = find("Episode 1"); let metadata = find("25 min avg"); let source = find(browser.source_checks.label(&video.id)); assert_eq!(metadata, (title.0, title.1 + 1)); assert_eq!(source, (title.0, title.1 + 2)); assert_eq!(browser.screen().selected_key(), Some(video.id.as_str())); for metadata in ["25 min avg", "TV", "1986 series"] { assert!(browser.screen().rows[0].details.contains(metadata)); } browser.screen_mut().toggle_checked(); terminal .draw(|frame| browser.draw(frame, Duration::ZERO)) .unwrap(); assert!(browser.state.is_watched(&video.id)); assert_eq!(browser.screen().checked, [video.id]); }