diff --git a/README.md b/README.md index c5e7949..3284c16 100644 --- a/README.md +++ b/README.md @@ -373,6 +373,8 @@ Video info shows a thumbnail, title, author/publication details, and a clipped d [Miruro API notes](docs/miruro-api.md) document the native `tubular::miruro` wrapper, source-document projection, stable episode/progress contracts, and verification limits. +Unit tests live in `tests/unit/`, grouped by source module and loaded with `#[cfg(test)]` and `#[path]` so they retain access to private implementation details. Cargo runs them with the commands below; they are not separate integration-test crates. + ```sh cargo fmt --check cargo test --locked diff --git a/src/config.rs b/src/config.rs index 33e6241..338143b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -758,1206 +758,5 @@ fn save(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()> } #[cfg(test)] -mod tests { - use super::*; - - struct TestDirectory(PathBuf); - - impl TestDirectory { - fn new() -> Self { - loop { - let path = std::env::temp_dir().join(format!( - "tubular-config-test-{}-{}", - std::process::id(), - NEXT_TEMP.fetch_add(1, Ordering::Relaxed) - )); - match fs::create_dir(&path) { - Ok(()) => return Self(path), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, - Err(error) => panic!("Cannot create test directory: {error}"), - } - } - } - } - - impl Drop for TestDirectory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[test] - fn existing_edge_separator_handles_can_be_saved_and_reloaded() { - let channel = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; - for handle in ["@hontame_", "@_creator", "@.creator-"] { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let tags = BTreeMap::from([("Immersion".to_owned(), true)]); - let saved = set_channel_tags(&path, &tags, handle, channel).unwrap(); - assert_eq!(saved.channels[handle], channel); - assert_eq!(saved.categories["Immersion"], [channel]); - let reloaded = load(&path).unwrap(); - assert_eq!(reloaded.channels, saved.channels); - assert_eq!(reloaded.categories, saved.categories); - let unchecked = BTreeMap::from([("Immersion".to_owned(), false)]); - set_channel_tags( - &path, - &unchecked, - &format!("https://www.youtube.com/{handle}"), - channel, - ) - .unwrap(); - let reloaded = load(&path).unwrap(); - assert!(reloaded.categories["Immersion"].is_empty()); - assert_eq!(reloaded.channels[handle], channel); - assert_eq!(reloaded.channels.len(), 1); - } - } - - #[test] - fn key_remaps_survive_all_saves_and_section_orders() { - use crate::keys::{Action, Context}; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; - let sections = [ - ("keys", key_section), - ("settings", "[settings]\npinned_categories = []\n"), - ( - "channels", - "[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n", - ), - ("categories", "[categories]\nA = ['@example']\nB = []\n"), - ]; - for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] { - let original = order - .iter() - .map(|index| sections[*index].1) - .collect::(); - fs::write(&path, original).unwrap(); - let loaded = load(&path).unwrap(); - for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { - assert_eq!( - loaded.keys.resolve( - Context::Normal, - KeyEvent::new(KeyCode::Char('x'), modifiers) - ), - Some(Action::Quit), - ); - } - assert_eq!( - loaded.keys.resolve( - Context::Normal, - KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE) - ), - None, - ); - assert_eq!( - loaded.keys.resolve( - Context::SelectorNormal, - KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) - ), - Some(Action::SelectorPin), - ); - assert_eq!( - loaded.keys.resolve( - Context::Normal, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) - ), - None - ); - let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); - let channel_saved = - set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); - let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]); - let category_saved = - set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap(); - let pin_saved = set_category_pinned(&path, "B", true).unwrap(); - for config in [ - channel_saved, - category_saved, - pin_saved, - load(&path).unwrap(), - ] { - assert_eq!( - config.keys.resolve( - Context::Normal, - KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE) - ), - Some(Action::Quit) - ); - assert_eq!( - config.keys.resolve( - Context::Normal, - KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) - ), - None - ); - assert_eq!( - config.keys.resolve( - Context::SelectorNormal, - KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) - ), - Some(Action::SelectorPin) - ); - } - let saved = fs::read_to_string(&path).unwrap(); - assert!( - saved.contains(key_section), - "key formatting changed: {saved}" - ); - let document = saved.parse::().unwrap(); - assert_eq!( - document.iter().map(|(name, _)| name).collect::>(), - order - .iter() - .map(|index| sections[*index].0) - .collect::>(), - ); - } - } - - #[test] - fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); - let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()]; - for invalid_keys in [ - "[keys]\napp.quit = 42\n", - "[keys]\nunknown.action = 'x'\n", - "[keys]\napp.quit = 'not-a-key'\n", - "[keys]\nview.refresh = 'q'\n", - "keys = 'invalid'\n", - ] { - let original = format!( - "{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n" - ); - fs::write(&path, &original).unwrap(); - assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData); - let channel_error = - set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err(); - assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err(); - assert_eq!(category_error.kind(), io::ErrorKind::InvalidData); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - let pin_error = set_category_pinned(&path, "B", true).unwrap_err(); - assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - let create_error = create_category(&path, "New", &expected).unwrap_err(); - assert_eq!(create_error.kind(), io::ErrorKind::InvalidData); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - } - } - - #[test] - fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let sections = [ - ( - "settings", - "[settings]\npinned_categories = ['A']\ncustom = 42\n", - ), - ( - "channels", - "[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n", - ), - ("categories", "[categories]\nA = []\nB = ['@example']\n"), - ]; - for order in [ - [0, 1, 2], - [0, 2, 1], - [1, 0, 2], - [1, 2, 0], - [2, 0, 1], - [2, 1, 0], - ] { - let original = order - .iter() - .map(|index| sections[*index].1) - .collect::(); - fs::write(&path, original).unwrap(); - let config = load(&path).unwrap(); - assert_eq!(config.pinned_categories, ["A"]); - assert_eq!( - config.categories["B"], - ["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"] - ); - assert_eq!( - set_category_pinned(&path, "B", true) - .unwrap() - .pinned_categories, - ["A", "B"] - ); - assert_eq!( - set_category_pinned(&path, "A", false) - .unwrap() - .pinned_categories, - ["B"] - ); - let saved = load(&path).unwrap(); - assert_eq!(saved.pinned_categories, ["B"]); - assert_eq!(saved.categories, config.categories); - assert_eq!(saved.channels, config.channels); - let contents = fs::read_to_string(&path).unwrap(); - let document = contents.parse::().unwrap(); - assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); - assert_eq!( - document.iter().map(|(name, _)| name).collect::>(), - order - .iter() - .map(|index| sections[*index].0) - .collect::>(), - ); - } - for original in [ - "[categories]\nA = []\n", - "[settings]\ncustom = 42\n[categories]\nA = []\n", - ] { - fs::write(&path, original).unwrap(); - set_category_pinned(&path, "A", true).unwrap(); - assert_eq!(load(&path).unwrap().pinned_categories, ["A"]); - } - } - - #[test] - fn category_pins_persist_without_changing_memberships_or_manual_preferences() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap(); - let pinned = set_category_pinned(&path, "Chinese", true).unwrap(); - assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]); - let tags = BTreeMap::from([("Japanese".into(), true)]); - let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); - assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]); - assert_eq!( - saved.categories["Japanese"], - ["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"] - ); - let unpinned = set_category_pinned(&path, "Japanese", false).unwrap(); - assert_eq!(unpinned.pinned_categories, ["Chinese"]); - assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("# favorites") && content.contains("# pin note")); - assert_eq!( - content.parse::().unwrap()["other"]["value"].as_integer(), - Some(42) - ); - assert!(set_category_pinned(&path, "Missing", true).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), content); - for invalid in [ - "[settings]\npinned_categories = true", - "[settings]\npinned_categories = [4]", - "[settings]\npinned_categories = ['A', ' A ']", - "settings = []", - "pinned_categories = ['A']", - ] { - fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap(); - let before = fs::read(&path).unwrap(); - assert!(set_category_pinned(&path, "A", true).is_err()); - assert!( - set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err() - ); - assert_eq!(fs::read(&path).unwrap(), before); - } - } - - #[test] - fn category_creation_deduplicates_aliases_and_preserves_original_config() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let url = format!("https://www.youtube.com/channel/{id}"); - let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n"; - fs::write(&path, original).unwrap(); - let before = load(&path).unwrap(); - let created = create_category( - &path, - " Combined ", - &[ - id.to_owned(), - url.clone(), - "@zeta".to_owned(), - "https://youtube.com/@alpha".to_owned(), - "@manual".to_owned(), - "https://www.youtube.com/@manual".to_owned(), - "UCabcdefghijklmnopqrstuv".to_owned(), - ], - ) - .unwrap(); - assert_eq!( - created.categories["Combined"], - [ - "https://www.youtube.com/@manual".to_owned(), - url, - "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(), - ] - ); - assert_eq!(created.channels, before.channels); - assert_eq!(created.categories["Source"], before.categories["Source"]); - assert_eq!(created.categories["Empty"], before.categories["Empty"]); - assert_eq!(created.pinned_categories, before.pinned_categories); - let contents = fs::read_to_string(&path).unwrap(); - let mut document = contents.parse::().unwrap(); - assert_eq!( - document["categories"]["Combined"] - .as_array() - .unwrap() - .iter() - .map(|entry| entry.as_str().unwrap()) - .collect::>(), - [ - "@manual", - "@alpha", - "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv", - ] - ); - document["categories"] - .as_table_like_mut() - .unwrap() - .remove("Combined"); - assert_eq!(document.to_string(), original); - assert_eq!(load(&path).unwrap().categories, created.categories); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n"; - fs::write(&path, original).unwrap(); - let opened = load(&path).unwrap(); - let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv"); - fs::write(&path, &latest).unwrap(); - let created = create_category( - &path, - " 日本語 🎵 ", - &["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()], - ) - .unwrap(); - assert_ne!(created.channels, opened.channels); - assert_eq!( - created.categories["日本語 🎵"], - ["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"] - ); - assert_eq!(created.pinned_categories, ["Source"]); - let contents = fs::read_to_string(&path).unwrap(); - let document = contents.parse::().unwrap(); - let before_document = latest.parse::().unwrap(); - assert!(document["categories"].is_inline_table()); - assert_eq!( - document["categories"]["日本語 🎵"][0].as_str(), - Some("@mapped") - ); - for section in ["settings", "channels"] { - assert_eq!( - document[section].to_string(), - before_document[section].to_string() - ); - } - for name in ["Source", "Empty"] { - assert_eq!( - document["categories"][name].to_string(), - before_document["categories"][name].to_string() - ); - } - assert!(contents.contains("# categories note")); - let empty = create_category(&path, "New empty", &[]).unwrap(); - assert!(empty.categories["New empty"].is_empty()); - assert_eq!(empty.pinned_categories, ["Source"]); - let missing = directory.0.join("nested/config.toml"); - let empty = create_category(&missing, "Empty", &[]).unwrap(); - assert!(empty.categories["Empty"].is_empty()); - assert!(empty.channels.is_empty()); - assert!(empty.pinned_categories.is_empty()); - assert_eq!(load(&missing).unwrap().categories, empty.categories); - } - - #[test] - fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n"; - fs::write(&path, original).unwrap(); - for name in [ - "", - " ", - "Bad\nName", - "Bad\u{7f}Name", - "Source", - " Source ", - "Empty", - ] { - assert!(create_category(&path, name, &[]).is_err(), "{name:?}"); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - } - // Even an identical membership snapshot must not overwrite a category. - assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err()); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - for channel in [ - "", - "bad", - "@bad handle", - "@manual\n", - "https://example.com/@manual", - "https://www.youtube.com/@manual/videos", - ] { - assert!( - create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(), - "{channel:?}" - ); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - } - for malformed in [ - "[categories\n", - "categories = 3\n", - "[categories]\nSource = [7]\n", - "[categories]\nSource = []\n' Source ' = []\n", - "[channels]\n'@mapped' = '@other'\n", - "[settings]\npinned_categories = [7]\n", - "[keys.app]\nunknown = ['q']\n", - ] { - fs::write(&path, malformed).unwrap(); - assert!(create_category(&path, "New", &[]).is_err()); - assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes()); - } - let missing = directory.0.join("missing/config.toml"); - assert!(create_category(&missing, "Bad\nName", &[]).is_err()); - assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err()); - assert!(!missing.parent().unwrap().exists()); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_rename_preserves_memberships_pins_comments_and_section_order() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n"; - fs::write(&path, original).unwrap(); - let before = load(&path).unwrap(); - let before_document = original.parse::().unwrap(); - let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap(); - assert!(!renamed.categories.contains_key("Source")); - assert_eq!( - renamed.categories["Renamed group"], - before.categories["Source"] - ); - assert_eq!(renamed.categories["Keep"], before.categories["Keep"]); - assert_eq!(renamed.channels, before.channels); - assert_eq!(renamed.subscriptions(), before.subscriptions()); - assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]); - let contents = fs::read_to_string(&path).unwrap(); - let document = contents.parse::().unwrap(); - assert_eq!( - document.iter().map(|(name, _)| name).collect::>(), - ["other", "settings", "keys", "channels", "categories"] - ); - assert_eq!( - document["categories"] - .as_table_like() - .unwrap() - .iter() - .map(|(name, _)| name) - .collect::>(), - ["Before", "Renamed group", "Keep"] - ); - assert_eq!( - document["categories"]["Renamed group"].to_string(), - before_document["categories"][" Source "].to_string() - ); - for section in ["other", "keys", "channels"] { - assert_eq!( - document[section].to_string(), - before_document[section].to_string() - ); - } - assert_eq!(document["settings"]["player"].as_str(), Some("mpv")); - for comment in [ - "# manual preferences", - "# favorite", - "# pin note", - "# category note", - "# entry note", - "# array note", - "# key note", - "# mapping note", - ] { - assert!(contents.contains(comment), "lost {comment}"); - } - assert_eq!(load(&path).unwrap().categories, renamed.categories); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_rename_preserves_inline_tables_and_reads_latest_memberships() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n"; - fs::write(&path, original).unwrap(); - let opened = load(&path).unwrap(); - let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'"); - fs::write(&path, &latest).unwrap(); - let renamed = rename_category(&path, "Source", "Renamed").unwrap(); - assert_eq!(opened.categories["Source"].len(), 1); - assert_eq!( - renamed.categories["Renamed"], - [ - "https://www.youtube.com/@first", - "https://www.youtube.com/@late", - ] - ); - assert!(!renamed.categories.contains_key("Source")); - assert_eq!(renamed.pinned_categories, ["Renamed"]); - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!( - contents, - latest - .replace("Source =", "Renamed =") - .replace("'Source'", "\"Renamed\"") - ); - let document = contents.parse::().unwrap(); - assert!(document["categories"].is_inline_table()); - assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n"; - fs::write(&path, original).unwrap(); - for (old, new) in [ - ("Source", ""), - ("Source", " \t "), - ("Source", "Bad\nName"), - ("", "New"), - ("Missing", "New"), - ("Missing", "Missing"), - ("Source", "Keep"), - ("Source", " Keep "), - ] { - assert!( - rename_category(&path, old, new).is_err(), - "{old:?} -> {new:?}" - ); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - } - let unchanged = rename_category(&path, " Source ", "Source").unwrap(); - assert_eq!(unchanged.categories, load(&path).unwrap().categories); - assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); - for invalid in [ - "[settings]\npinned_categories = [7]\n[categories]\nSource = []\n", - "[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n", - "[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n", - "[categories]\nSource = [7]\n", - "[categories]\nSource = []\n' Source ' = []\n", - ] { - fs::write(&path, invalid).unwrap(); - assert!(rename_category(&path, "Source", "New").is_err()); - assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes()); - } - let missing = directory.0.join("missing/config.toml"); - assert!(rename_category(&missing, "Source", "New").is_err()); - assert!(!missing.parent().unwrap().exists()); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let first = "UC4QobU6STFB0P71PMvOGN5A"; - let second = "UCabcdefghijklmnopqrstuv"; - let direct = "UC0123456789012345678901"; - let first_url = format!("https://www.youtube.com/channel/{first}"); - let second_url = format!("https://www.youtube.com/channel/{second}"); - let direct_url = format!("https://www.youtube.com/channel/{direct}"); - let manual_url = "https://www.youtube.com/@manual".to_owned(); - let original = format!( - "# manual preferences\n[settings]\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n" - ); - fs::write(&path, &original).unwrap(); - let opened = load(&path).unwrap(); - let before = original.parse::().unwrap(); - // This category was not present in the dialog's tags. - fs::write( - &path, - format!("{original}Late = ['@alias', '@unrelated'] # late note\n"), - ) - .unwrap(); - let tags = BTreeMap::from([ - ("Source".to_owned(), false), - ("Keep".to_owned(), true), - ("Drop".to_owned(), false), - (" New ".to_owned(), true), - ]); - let saved = - set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap(); - assert!(saved.categories["Source"].is_empty()); - for name in ["Drop", "Late", "Untouched"] { - assert_eq!( - saved.categories[name], - ["https://www.youtube.com/@unrelated"] - ); - } - assert_eq!( - saved.categories["Keep"], - [ - first_url.clone(), - "https://www.youtube.com/@unrelated".to_owned(), - manual_url.clone(), - direct_url.clone(), - second_url.clone(), - ] - ); - assert_eq!( - saved.categories["New"], - [manual_url, direct_url, first_url, second_url] - ); - assert_eq!(saved.channels, opened.channels); - assert_eq!(saved.pinned_categories, ["Source"]); - let content = fs::read_to_string(&path).unwrap(); - let document = content.parse::().unwrap(); - assert_eq!( - document["channels"].to_string(), - before["channels"].to_string() - ); - assert_eq!( - document["settings"]["pinned_categories"].to_string(), - before["settings"]["pinned_categories"].to_string() - ); - assert_eq!(document["other"]["value"].as_integer(), Some(42)); - assert_eq!(document["settings"]["player"].as_str(), Some("mpv")); - let new = document["categories"]["New"].as_array().unwrap(); - let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect(); - assert_eq!(raw, ["@manual", direct, "@alias", "@second"]); - let keep = document["categories"]["Keep"].as_array().unwrap(); - assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias")); - assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated")); - for comment in [ - "# manual preferences", - "# pin note", - "# alias mapping", - "# source channel", - "# direct channel", - "# source note", - "# keep note", - "# removed channel note", - "# unrelated note", - "# late note", - ] { - assert!(content.contains(comment), "lost {comment}"); - } - let reloaded = load(&path).unwrap(); - assert_eq!(reloaded.categories, saved.categories); - assert_eq!(reloaded.subscriptions(), saved.subscriptions()); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - - #[test] - fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let url = format!("https://www.youtube.com/channel/{id}"); - let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n"); - fs::write( - &path, - format!("{registry}[categories]\nSource = ['@old', '@manual']\n"), - ) - .unwrap(); - let expected = load(&path).unwrap().categories.remove("Source").unwrap(); - fs::write( - &path, - format!( - "{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n" - ), - ) - .unwrap(); - let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]); - let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap(); - assert_eq!( - saved.categories["Source"], - ["https://www.youtube.com/@manual".to_owned(), url.clone()] - ); - assert_eq!( - saved.categories["New"], - ["https://www.youtube.com/@manual".to_owned(), url] - ); - let content = fs::read_to_string(&path).unwrap(); - let document = content.parse::().unwrap(); - let source = document["categories"]["Source"].as_array().unwrap(); - assert_eq!(source.len(), 3); - assert_eq!(source.get(2).unwrap().as_str(), Some("@alias")); - let new = document["categories"]["New"].as_array().unwrap(); - assert_eq!(new.get(1).unwrap().as_str(), Some("@alias")); - assert!(content.contains("# alias note")); - set_category_tags(&path, "Source", &expected, &tags).unwrap(); - assert_eq!(fs::read_to_string(&path).unwrap(), content); - } - - #[test] - fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let tags = BTreeMap::from([("New".to_owned(), true)]); - let expected = vec!["https://www.youtube.com/@manual".to_owned()]; - for contents in [ - "[categories]\nOther = ['@manual']\n", - "[categories]\nSource = []\n", - "[categories]\nSource = ['@different']\n", - "[categories]\nSource = ['@manual', '@different']\n", - "[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n", - "[categories\n", - "categories = true\n", - "[categories]\nSource = ['@manual']\nInvalid = [7]\n", - "[categories]\nSource = ['@manual']\n' Source ' = []\n", - "channels = true\n[categories]\nSource = ['@manual']\n", - "[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n", - "[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n", - ] { - fs::write(&path, contents).unwrap(); - assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), contents); - assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); - } - fs::write(&path, [0xff]).unwrap(); - assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); - assert_eq!(fs::read(&path).unwrap(), [0xff]); - let valid = "[categories]\nSource = ['@manual']\n"; - fs::write(&path, valid).unwrap(); - for invalid_tags in [ - BTreeMap::from([(" ".to_owned(), true)]), - BTreeMap::from([("bad\nname".to_owned(), true)]), - BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]), - ] { - assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), valid); - } - assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), valid); - let missing = directory.0.join("missing/config.toml"); - assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err()); - assert!(!missing.parent().unwrap().exists()); - } - - #[test] - fn category_tags_only_clear_all_tags_when_every_target_remains_registered() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let registry = format!( - "[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n" - ); - for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] { - let original = format!("{registry}Source = [{source}]\n"); - fs::write(&path, &original).unwrap(); - let opened = load(&path).unwrap(); - for tags in [ - BTreeMap::new(), - BTreeMap::from([("Source".to_owned(), false)]), - ] { - let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags) - .unwrap_err(); - assert!(error.to_string().contains("not registered in [channels]")); - assert_eq!(fs::read_to_string(&path).unwrap(), original); - } - } - let original = format!( - "{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n" - ); - fs::write(&path, &original).unwrap(); - let opened = load(&path).unwrap(); - let saved = set_category_tags( - &path, - "Source", - &opened.categories["Source"], - &BTreeMap::new(), - ) - .unwrap(); - assert!(saved.categories["Source"].is_empty()); - assert_eq!( - saved.categories["Other"], - ["https://www.youtube.com/@unrelated"] - ); - assert_eq!(saved.channels, opened.channels); - assert_eq!(saved.pinned_categories, ["Source"]); - assert_eq!( - saved.subscriptions(), - [ - "https://www.youtube.com/@unrelated", - "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", - ] - ); - assert!(fs::read_to_string(&path).unwrap().contains("# source note")); - } - - #[test] - fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { - let directory = TestDirectory::new(); - let path = directory.0.join("nested/config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - assert!(load(&path).unwrap().categories.is_empty()); - assert!(!path.parent().unwrap().exists()); - let tags = BTreeMap::from([(" Music ".to_owned(), true)]); - let config = set_channel_tags(&path, &tags, "@example", id).unwrap(); - assert_eq!( - config.channels["@example"], - format!("https://www.youtube.com/channel/{id}") - ); - assert_eq!( - config.categories["Music"], - [format!("https://www.youtube.com/channel/{id}")] - ); - - let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n"; - fs::write(&path, manual).unwrap(); - let tags = BTreeMap::from([("Music".to_owned(), true)]); - let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap(); - assert_eq!(config.categories["Music"].len(), 1); - assert!(config.categories["Empty"].is_empty()); - let id = "UCabcdefghijklmnopqrstuv"; - let config = set_channel_tags(&path, &tags, "@another", id).unwrap(); - assert_eq!( - config.categories["Music"][1], - format!("https://www.youtube.com/channel/{id}") - ); - let saved = fs::read_to_string(&path).unwrap(); - for comment in [ - "# manual preferences", - "# keep me", - "# favorites", - "# channel note", - ] { - assert!(saved.contains(comment)); - } - assert_eq!( - saved.parse::().unwrap()["other"]["value"].as_integer(), - Some(42) - ); - - for malformed in [ - "[categories\n", - "categories = 3\n", - "[categories]\nMusic = [3]\n", - "[categories]\nMusic = ['https://example.com/@example']\n", - "[categories]\nMusic = []\n' Music ' = []\n", - "channels = 3\n", - "[channels]\n'@example' = 3\n", - "[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n", - "[channels]\n'@example' = '@another'\n", - "[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n", - "[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n", - ] { - fs::write(&path, malformed).unwrap(); - assert!(load(&path).is_err()); - assert!(set_channel_tags(&path, &tags, "@example", id).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), malformed); - } - fs::write(&path, manual).unwrap(); - for tags in [ - BTreeMap::from([(" ".to_owned(), true)]), - BTreeMap::from([("bad\nname".to_owned(), false)]), - BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]), - ] { - assert!(set_channel_tags(&path, &tags, "@example", id).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), manual); - } - assert!( - set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err() - ); - assert_eq!(fs::read_to_string(&path).unwrap(), manual); - assert!(save(&path, Some(b"previous contents"), b"replacement").is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), manual); - assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); - } - - #[test] - fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let url = format!("https://www.youtube.com/channel/{id}"); - let manual = format!( - "[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n" - ); - fs::write(&path, manual).unwrap(); - let config = load(&path).unwrap(); - assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); - assert_eq!( - config.categories["Direct"], - ["https://www.youtube.com/@RustVideos"] - ); - let tags = BTreeMap::from([("Japanese".to_owned(), true)]); - let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap(); - assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); - assert_eq!(config.categories["Other"], std::slice::from_ref(&url)); - let saved = fs::read_to_string(&path).unwrap(); - let document = saved.parse::().unwrap(); - let entries = document["categories"]["Japanese"].as_array().unwrap(); - assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed"))); - assert_eq!(entries.len(), 2); - assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str())); - assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str())); - assert!(saved.contains("# keep this entry comment")); - set_channel_tags(&path, &tags, "@jawed", id).unwrap(); - assert_eq!(fs::read_to_string(&path).unwrap(), saved); - assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), saved); - assert_eq!(load(&path).unwrap().categories["Japanese"], [url]); - } - - #[test] - fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let url = format!("https://www.youtube.com/channel/{id}"); - let other_id = "UCabcdefghijklmnopqrstuv"; - let other_url = format!("https://www.youtube.com/channel/{other_id}"); - let manual = format!( - "# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n" - ); - fs::write(&path, &manual).unwrap(); - let tags = BTreeMap::from([ - ("First".to_owned(), true), - ("Second".to_owned(), true), - ("New empty".to_owned(), false), - ]); - let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); - assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]); - assert_eq!( - config.categories["Second"], - [other_url.clone(), url.clone()] - ); - assert!(config.categories["New empty"].is_empty()); - let saved = fs::read_to_string(&path).unwrap(); - let document = saved.parse::().unwrap(); - let entries = document["categories"][" First "].as_array().unwrap(); - assert_eq!(entries.len(), 5); - assert!( - entries - .iter() - .skip(1) - .all(|entry| entry.as_str() == Some("@current")) - ); - assert_eq!( - document["categories"]["Omitted"] - .as_array() - .unwrap() - .get(0) - .unwrap() - .as_str(), - Some("@old") - ); - assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str())); - assert_eq!( - document["channels"]["@other"].as_str(), - Some(other_url.as_str()) - ); - - // Restore manually written aliases so deselection exercises every accepted spelling. - fs::write(&path, &manual).unwrap(); - let tags = BTreeMap::from([ - ("First".to_owned(), false), - ("Second".to_owned(), true), - ("New empty".to_owned(), false), - ]); - let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); - assert_eq!(config.categories["First"], std::slice::from_ref(&other_url)); - assert_eq!( - config.categories["Second"], - [other_url.clone(), url.clone()] - ); - assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url)); - assert!(config.categories["New empty"].is_empty()); - let saved = fs::read_to_string(&path).unwrap(); - for comment in [ - "# subscriptions", - "# old alias", - "# other mapping", - "# unrelated creator", - "# first tag", - "# untouched tag", - ] { - assert!(saved.contains(comment), "lost {comment}"); - } - let tags = BTreeMap::from([ - ("First".to_owned(), false), - ("Second".to_owned(), false), - ("Omitted".to_owned(), false), - ]); - set_channel_tags(&path, &tags, "@current", id).unwrap(); - let config = load(&path).unwrap(); - assert_eq!(config.categories["First"], std::slice::from_ref(&other_url)); - assert_eq!(config.categories["Second"], [other_url]); - assert!(config.categories["Omitted"].is_empty()); - assert!(config.categories["New empty"].is_empty()); - assert!( - fs::read_to_string(&path) - .unwrap() - .contains("# unrelated creator") - ); - } - - #[test] - fn saved_channels_use_readable_aliases_and_include_every_subscription_once() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let mapped_id = "UC4QobU6STFB0P71PMvOGN5A"; - let untagged_id = "UCabcdefghijklmnopqrstuv"; - let legacy_id = "UCzyxwvutsrqponmlkjihgfe"; - let mapped = format!("https://www.youtube.com/channel/{mapped_id}"); - let untagged = format!("https://www.youtube.com/channel/{untagged_id}"); - let legacy = format!("https://www.youtube.com/channel/{legacy_id}"); - fs::write( - &path, - format!( - "[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n" - ), - ) - .unwrap(); - let config = load(&path).unwrap(); - let expected = vec![ - ("@aAlias".to_owned(), mapped.clone()), - ("@untagged".to_owned(), untagged.clone()), - ( - "https://www.youtube.com/@manual".to_owned(), - "https://www.youtube.com/@manual".to_owned(), - ), - (legacy.clone(), legacy.clone()), - ]; - assert_eq!(config.saved_channels(), expected); - assert_eq!(load(&path).unwrap().saved_channels(), expected); - let mut identities: Vec<_> = config - .saved_channels() - .into_iter() - .map(|(_, identity)| identity) - .collect(); - identities.sort_unstable(); - assert_eq!(identities, config.subscriptions()); - assert!( - config - .categories - .values() - .flatten() - .all(|id| id != &untagged) - ); - assert_eq!(config.channels["@zAlias"], mapped); - } - - #[test] - fn saved_channels_support_empty_and_registry_only_configs() { - assert!(Config::default().saved_channels().is_empty()); - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; - fs::write( - &path, - format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"), - ) - .unwrap(); - let config = load(&path).unwrap(); - assert!(config.categories.is_empty()); - assert_eq!( - config.saved_channels(), - vec![("@aAlias".to_owned(), identity.to_owned())] - ); - assert_eq!(config.subscriptions(), [identity]); - } - - #[test] - fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() { - let directory = TestDirectory::new(); - let path = directory.0.join("config.toml"); - let id = "UC4QobU6STFB0P71PMvOGN5A"; - let url = format!("https://www.youtube.com/channel/{id}"); - let other_id = "UCabcdefghijklmnopqrstuv"; - let other_url = format!("https://www.youtube.com/channel/{other_id}"); - let registry = - format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n"); - fs::write(&path, ®istry).unwrap(); - let config = load(&path).unwrap(); - assert!(config.categories.is_empty()); - assert_eq!(config.channels["@old"], url); - assert_eq!(config.subscriptions(), std::slice::from_ref(&url)); - - let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap(); - assert!(config.categories.is_empty()); - assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]); - assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions()); - - fs::write( - &path, - format!( - "{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n" - ), - ) - .unwrap(); - let expected = vec![ - "https://www.youtube.com/@RustVideos".to_owned(), - url.clone(), - other_url, - ]; - let config = load(&path).unwrap(); - assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url)); - assert_eq!(config.subscriptions(), expected); - let tags = BTreeMap::from([("Tagged".to_owned(), false)]); - let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); - assert!(config.categories["Tagged"].is_empty()); - assert_eq!(config.subscriptions(), expected); - assert_eq!(load(&path).unwrap().subscriptions(), expected); - } - - #[test] - fn paths_and_channel_addresses_are_strict() { - let home = Some(OsString::from("/home/test")); - let fallback = PathBuf::from("/home/test/.config/tubular/config.toml"); - for xdg in [ - None, - Some(OsString::new()), - Some(OsString::from("relative")), - ] { - assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback); - } - assert_eq!( - resolve_path(Some("/config".into()), None).unwrap(), - PathBuf::from("/config/tubular/config.toml") - ); - assert!(resolve_path(None, None).is_err()); - assert!(resolve_path(None, Some(OsString::new())).is_err()); - for channel in [ - "", - "@", - "@bad/name", - "@bad?query", - "@bad#fragment", - "@bad\n", - "https://www.youtube.com.evil/@example", - "http://www.youtube.com/@example", - "https://www.youtube.com/channel/UCshort", - "channel/UCshort", - "https://www.youtube.com/@example/videos", - "https://www.youtube.com/@example?x=1", - "https://www.youtube.com/@example/../other", - "https://www.youtube.com/@example//", - ] { - assert!(normalize_channel(channel).is_err(), "accepted {channel:?}"); - } - assert_eq!( - normalize_channel("@example").unwrap(), - "https://www.youtube.com/@example" - ); - assert_eq!( - normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(), - "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv" - ); - assert!(category_name(" \t ").is_err()); - } -} +#[path = "../tests/unit/config.rs"] +mod tests; diff --git a/src/keys.rs b/src/keys.rs index f5dbe9c..2689a61 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -808,565 +808,5 @@ pub fn default_config() -> String { } #[cfg(test)] -mod tests { - use super::*; - use toml_edit::DocumentMut; - - fn configured(source: &str) -> io::Result { - let document = source.parse::().unwrap(); - Bindings::from_toml(document.get("keys")) - } - - fn press( - bindings: &Bindings, - context: Context, - code: KeyCode, - modifiers: KeyModifiers, - ) -> Option { - bindings.resolve(context, KeyEvent::new(code, modifiers)) - } - - #[test] - fn only_section_headers_are_bold_in_the_grouped_overview() { - use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Modifier, - widgets::{Paragraph, Widget, Wrap}, - }; - let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap(); - for width in [21, 64] { - let area = Rect::new(0, 0, width, 16); - let mut buffer = Buffer::empty(area); - Paragraph::new(bindings.help_text()) - .wrap(Wrap { trim: false }) - .render(area, &mut buffer); - assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD)); - for y in 2..area.height { - for x in 0..width { - assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); - } - } - let binding_visible = (2..area.height).any(|y| { - (0..width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - .contains("F24") - }); - assert!( - binding_visible, - "custom shortcut must remain visible after wrapping" - ); - } - } - - #[test] - fn contextual_cycle_and_direct_preview_bindings() { - let defaults = Bindings::default(); - for (context, action) in [ - (Context::Normal, Action::InfoOverview), - (Context::Info, Action::InfoDescription), - (Context::Description, Action::InfoOverview), - (Context::CollectionNormal, Action::InfoOverview), - (Context::CollectionInfo, Action::InfoDescription), - (Context::CollectionDescription, Action::InfoOverview), - ] { - assert_eq!( - press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE), - Some(action) - ); - } - let bindings = - configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n") - .unwrap(); - for context in VIEW { - assert_eq!( - press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE), - Some(Action::InfoOverview) - ); - assert_eq!( - press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE), - Some(Action::InfoDescription) - ); - } - assert_eq!( - press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE), - Some(Action::InfoHide) - ); - assert_eq!( - press( - &defaults, - Context::Description, - KeyCode::Char('-'), - KeyModifiers::NONE - ), - Some(Action::ViewPrevious) - ); - assert_eq!( - press( - &defaults, - Context::Description, - KeyCode::Down, - KeyModifiers::NONE - ), - Some(Action::DescriptionNext) - ); - assert_eq!( - press( - &defaults, - Context::Description, - KeyCode::Char('j'), - KeyModifiers::NONE - ), - Some(Action::VideoNext) - ); - assert_eq!( - press( - &defaults, - Context::Description, - KeyCode::Char('j'), - KeyModifiers::CONTROL - ), - Some(Action::DescriptionNext) - ); - assert_eq!( - press( - &defaults, - Context::SearchInput, - KeyCode::Char('d'), - KeyModifiers::NONE - ), - None - ); - assert_eq!( - press( - &defaults, - Context::SelectorNormal, - KeyCode::Char('j'), - KeyModifiers::NONE - ), - Some(Action::SelectorNext) - ); - assert_eq!( - press( - &defaults, - Context::SelectorInput, - KeyCode::Char('j'), - KeyModifiers::NONE - ), - None - ); - for context in ALL { - assert_eq!( - press( - &defaults, - *context, - KeyCode::Char('c'), - KeyModifiers::CONTROL - ), - Some(Action::Interrupt) - ); - } - } - - #[test] - fn modifier_normalization_is_exact_and_allocation_free() { - let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap(); - for (code, modifiers) in [ - (KeyCode::Char('I'), KeyModifiers::NONE), - (KeyCode::Char('I'), KeyModifiers::SHIFT), - (KeyCode::Char('i'), KeyModifiers::SHIFT), - ] { - assert_eq!( - press(&bindings, Context::Normal, code, modifiers), - Some(Action::InfoOverview) - ); - } - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('i'), - KeyModifiers::NONE - ), - None - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('I'), - KeyModifiers::ALT - ), - None - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('?'), - KeyModifiers::SHIFT - ), - Some(Action::Help) - ); - for (code, modifiers) in [ - (KeyCode::BackTab, KeyModifiers::NONE), - (KeyCode::BackTab, KeyModifiers::SHIFT), - (KeyCode::Tab, KeyModifiers::SHIFT), - ] { - assert_eq!( - press(&bindings, Context::Normal, code, modifiers), - Some(Action::ViewTabPrevious) - ); - } - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('+'), - KeyModifiers::CONTROL - ), - Some(Action::ViewNext) - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('+'), - KeyModifiers::NONE - ), - None - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::F(24), - KeyModifiers::ALT - ), - Some(Action::ViewRefresh) - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('界'), - KeyModifiers::SUPER - ), - Some(Action::SearchEdit) - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('c'), - KeyModifiers::CONTROL | KeyModifiers::ALT - ), - None - ); - } - - #[test] - fn aliases_replace_defaults_and_can_disable_actions() { - let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); - let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); - assert_eq!(quoted, bindings); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('r'), - KeyModifiers::NONE - ), - None - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('q'), - KeyModifiers::NONE - ), - None - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('x'), - KeyModifiers::NONE - ), - Some(Action::ViewRefresh) - ); - assert_eq!( - press( - &bindings, - Context::Normal, - KeyCode::Char('y'), - KeyModifiers::CONTROL - ), - Some(Action::ViewRefresh) - ); - } - - #[test] - fn collection_removal_defaults_are_scoped_and_inherit_view_actions() { - let bindings = Bindings::default(); - for &context in ALL { - for (key, action) in [ - ('x', Action::CollectionRemoveSelected), - ('X', Action::CollectionRemoveBulk), - ] { - assert_eq!( - press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), - COLLECTION.contains(&context).then_some(action), - ); - } - assert_eq!( - press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE), - None, - ); - } - for (ordinary, collection) in [ - (Context::Normal, Context::CollectionNormal), - (Context::Info, Context::CollectionInfo), - (Context::Description, Context::CollectionDescription), - ] { - for &(key, action) in &bindings.contexts[ordinary as usize] { - assert_eq!( - bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)), - Some(action), - "{ordinary:?} / {collection:?}: {key:?}", - ); - } - assert_eq!( - bindings.contexts[collection as usize].len(), - bindings.contexts[ordinary as usize].len() + 2, - ); - } - } - - #[test] - fn collection_overrides_aliases_normalize_and_disable() { - let bindings = configured( - "[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n", - ) - .unwrap(); - let quoted = configured( - "[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n", - ).unwrap(); - assert_eq!(bindings, quoted); - assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x"); - assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B"); - let help = bindings.help_text().to_string(); - assert!(help.contains("Alt+x")); - assert!(help.lines().any(|line| line.trim_start().starts_with("B "))); - for &context in ALL { - for (code, modifiers, action) in [ - ( - KeyCode::Char('x'), - KeyModifiers::ALT, - Action::CollectionRemoveSelected, - ), - ( - KeyCode::Char('b'), - KeyModifiers::SHIFT, - Action::CollectionRemoveBulk, - ), - ( - KeyCode::Char('B'), - KeyModifiers::NONE, - Action::CollectionRemoveBulk, - ), - ] { - assert_eq!( - press(&bindings, context, code, modifiers), - COLLECTION.contains(&context).then_some(action), - ); - } - for key in ['x', 'X'] { - assert_eq!( - press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), - None - ); - } - } - let disabled = configured( - "[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n", - ).unwrap(); - for &context in ALL { - for key in ['x', 'X'] { - assert_eq!( - press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE), - VIEW.contains(&context).then_some(Action::ViewRefresh), - ); - } - } - } - - #[test] - fn collection_contexts_validate_inherited_binding_conflicts() { - for source in [ - "[keys]\nview.refresh = 'x'", - "[keys]\nview.watch_later.browse = 'X'", - "[keys]\nview.saved.browse = 'X'", - "[keys]\nview.collection.remove.selected = 'q'", - "[keys]\nview.collection.remove.bulk = 'down'", - ] { - let error = configured(source).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!(error.to_string().contains("Collection"), "{error}"); - } - } - - #[test] - fn collection_browse_shortcuts_are_distinct_and_view_scoped() { - let defaults = Bindings::default(); - let overrides = configured( - "[keys.view.watch_later]\ncategory = ['OpTiOn+b', 'alt+b']\n[keys]\n'view.saved.category' = []\n", - ) - .unwrap(); - for &context in ALL { - for (key, modifiers, action) in [ - ('W', KeyModifiers::NONE, Action::WatchLaterBrowse), - ('w', KeyModifiers::SHIFT, Action::WatchLaterBrowse), - ('V', KeyModifiers::NONE, Action::SavedVideoBrowse), - ('v', KeyModifiers::SHIFT, Action::SavedVideoBrowse), - ('w', KeyModifiers::ALT, Action::WatchLaterBrowseCategory), - ('v', KeyModifiers::ALT, Action::SavedVideoBrowseCategory), - ] { - assert_eq!( - press(&defaults, context, KeyCode::Char(key), modifiers), - VIEW.contains(&context).then_some(action), - ); - } - assert_eq!( - press(&overrides, context, KeyCode::Char('b'), KeyModifiers::ALT), - VIEW.contains(&context) - .then_some(Action::WatchLaterBrowseCategory), - ); - for key in ['w', 'v'] { - assert_eq!( - press(&overrides, context, KeyCode::Char(key), KeyModifiers::ALT), - None, - ); - } - for key in ['W', 'V'] { - assert_eq!( - press(&defaults, context, KeyCode::Char(key), KeyModifiers::ALT), - None, - ); - } - } - assert_eq!(overrides.label(Action::WatchLaterBrowseCategory), "Alt+b"); - } - - #[test] - fn collection_browsing_and_save_overrides_retain_contexts() { - let bindings = configured( - "[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\ncategory = []\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\ncategory = []\n[keys.view.collection.remove]\nbulk = []\n", - ).unwrap(); - for &context in ALL { - for (key, modifiers, action, enabled) in [ - ( - 'w', - KeyModifiers::ALT, - Action::WatchLaterSave, - VIEW.contains(&context), - ), - ( - 'v', - KeyModifiers::ALT, - Action::SavedVideoSave, - VIEW.contains(&context), - ), - ( - 'x', - KeyModifiers::SHIFT, - Action::WatchLaterBrowse, - VIEW.contains(&context), - ), - ( - 'f', - KeyModifiers::CONTROL, - Action::WatchLaterFilter, - matches!(context, Context::Normal | Context::CollectionNormal), - ), - ( - 'b', - KeyModifiers::ALT, - Action::SavedVideoBrowse, - VIEW.contains(&context), - ), - ] { - assert_eq!( - press(&bindings, context, KeyCode::Char(key), modifiers), - enabled.then_some(action) - ); - } - } - } - - #[test] - fn obsolete_removal_names_are_unknown() { - for name in [ - "view.watch_later.remove", - "video.watch_later.remove", - "video.saved.remove", - ] { - let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!(error.to_string().contains("unknown action"), "{error}"); - assert!(error.to_string().contains(name), "{error}"); - } - } - - #[test] - fn invalid_entries_and_collisions_have_actionable_errors() { - for (source, path) in [ - ("[keys]\nunknown = 'x'", "keys.unknown"), - ("[keys.unknown]", "keys.unknown"), - ("keys = []", "keys"), - ("[keys]\nview = 'x'", "keys.view"), - ("[keys]\nview.refresh = 1", "keys.view.refresh"), - ("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"), - ("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"), - ( - "[keys]\nview.refresh = 'ctrl+control+x'", - "keys.view.refresh", - ), - ("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"), - ("[keys]\nview.refresh = 'f25'", "keys.view.refresh"), - ("[keys]\nview.refresh = ''", "keys.view.refresh"), - ] { - let error = configured(source).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!(error.to_string().contains(path), "{error}"); - } - for source in [ - "[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'", - "[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'", - ] { - let error = configured(source).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!( - error.to_string().contains("keys.video.info.overview"), - "{error}" - ); - } - for source in [ - "[keys]\nview.refresh = 'q'", - "[keys]\nview.refresh = 'ctrl+c'", - "[keys]\nsearch.edit = 'shift+g'", - ] { - let error = configured(source).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - } - let collision = configured("[keys]\nview.refresh = 'q'") - .unwrap_err() - .to_string(); - assert!(collision.contains("view.refresh") && collision.contains("app.quit")); - // Disjoint contexts can use the same key, including an overridden input command. - assert!(configured("[keys]\ninput.submit = 'q'").is_ok()); - } -} +#[path = "../tests/unit/keys.rs"] +mod tests; diff --git a/src/main.rs b/src/main.rs index c1076b8..23b429f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -953,24 +953,6 @@ struct App { } impl App { - #[cfg(test)] - fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let path = std::env::temp_dir() - .join(format!( - "tubular-ui-state-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )) - .join("config.toml"); - Self::with_state( - options, - config_path, - config, - video_state::State::open(&path).unwrap(), - ) - } - fn with_state( mut options: Options, config_path: PathBuf, @@ -3738,5831 +3720,5 @@ fn main() -> io::Result<()> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel() { - use std::ops::ControlFlow::{Break, Continue}; - let mut selected = 0; - assert_eq!( - provider_selection(&mut selected, Some(Action::SelectorPrevious)), - Continue(()) - ); - for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] { - if action == Action::SelectorNext { - assert_eq!( - provider_selection(&mut selected, Some(action)), - Continue(()) - ); - assert_eq!( - provider_selection(&mut selected, Some(action)), - Continue(()) - ); - } - let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen)) - else { - panic!("Selected provider must open"); - }; - assert_eq!(choice, miruro); - let mut options = Options::parse_from(["tubular", "initial query"]); - options.miruro = choice; - assert_eq!(options.query.as_deref(), Some("initial query")); - assert_eq!(options.miruro, miruro); - if !miruro { - let app = App::new(options, PathBuf::new(), config::Config::default()); - assert_eq!(app.views[0].query, "initial query"); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - } - } - for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] { - assert_eq!(provider_selection(&mut selected, Some(action)), Break(None)); - } - assert_eq!(provider_selection(&mut selected, None), Continue(())); - } - - fn app(args: &[&str]) -> App { - App::new( - Options::parse_from(args), - PathBuf::new(), - config::Config::default(), - ) - } - - #[test] - fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() { - let mut app = app(&["tubular"]); - let youtube = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"YouTube","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@creator"}]}"#, - ).unwrap().videos.remove(0); - let mut miruro = youtube.clone(); - miruro.id = "miruro:catalog_ID:1".into(); - miruro.title = "Miruro episode".into(); - miruro.channel = Some("Miruro".into()); - miruro.channel_id = None; - miruro.uploader_id = None; - for kind in [ - CollectionKind::WatchLater, - CollectionKind::Saved, - CollectionKind::Watching, - ] { - app.video_state.save(kind, &youtube).unwrap(); - app.video_state.save(kind, &miruro).unwrap(); - app.browse_collection(kind); - assert_eq!(app.views[app.view_index].videos.len(), 1); - assert_eq!(app.views[app.view_index].videos[0].id, youtube.id); - assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); - app.remove_collection_bulk(); - assert_eq!( - app.collection_removal.as_ref().unwrap().ids.as_slice(), - std::slice::from_ref(&youtube.id) - ); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(!app.video_state.contains(kind, &youtube.id)); - assert!(app.video_state.contains(kind, &miruro.id)); - assert!(app.views[app.view_index].videos.is_empty()); - app.video_state.save(kind, &youtube).unwrap(); - app.rebuild_collections(); - app.remove_collection(); - assert!(!app.video_state.contains(kind, &youtube.id)); - assert!(app.video_state.contains(kind, &miruro.id)); - } - } - - #[test] - fn watching_direct_bindings_respect_modals_and_offline_history() { - for (label, overrides, binding) in [ - ("default", "", Some(KeyCode::Char('C'))), - ( - "remapped", - "[keys]\nview.watching.browse = 'f8'\n", - Some(KeyCode::F(8)), - ), - ("disabled", "[keys]\nview.watching.browse = []\n", None), - ] { - let mut app = rename_app(&format!("watching-{label}"), overrides); - for view in &mut app.views { - view.loaded = true; - } - app.mode = Mode::Insert; - rename_press(&mut app, KeyCode::Char('C')); - assert_eq!(app.views[0].query, "C"); - rename_press(&mut app, KeyCode::Esc); - app.open_browser(BrowserKind::Categories); - rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); - assert!(app.browser.is_some()); - assert_eq!(app.view_index, 0); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Char('?')); - rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); - assert!(app.help_popup.is_some()); - assert_eq!(app.view_index, 0); - rename_press(&mut app, KeyCode::Esc); - if binding != Some(KeyCode::Char('C')) { - rename_press(&mut app, KeyCode::Char('C')); - assert_eq!(app.view_index, 0); - } - if let Some(binding) = binding { - rename_press(&mut app, binding); - let index = app.view_index; - assert_eq!(app.views[index].source, ViewSource::Watching); - assert_eq!(app.temporary_view, Some(ViewSource::Watching)); - assert_eq!(app.key_context(), Context::CollectionNormal); - assert!(app.browser.is_none()); - app.load_page(); - let config = std::mem::take(&mut app.config); - app.apply_config(config); - assert_eq!(app.views[app.view_index].source, ViewSource::Watching); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.view_index, index); - rename_press(&mut app, KeyCode::Char('+')); - assert_eq!(app.view_index, 0); - rename_press(&mut app, binding); - assert_eq!(app.view_index, index); - assert!(app.job.is_none()); - assert!(!app.views[index].has_more); - } - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - } - - #[test] - fn watching_progress_preserves_selection_and_removal_is_collection_only() { - use std::io::Write; - fn report(path: &std::path::Path, id: &str, position: u32) { - let mut options = std::fs::OpenOptions::new(); - options.append(true).create(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - writeln!( - options.open(path).unwrap(), - "{{\"version\":1,\"event\":\"progress\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":true}}" - ).unwrap(); - } - let mut app = rename_app("watching-progress", ""); - for view in &mut app.views { - view.loaded = true; - } - let videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Third","uploader_id":"@third"}]}"#, - ).unwrap().videos; - let reports: Vec<_> = videos - .iter() - .map(|video| { - app.video_state.save(CollectionKind::Saved, video).unwrap(); - app.video_state - .save(CollectionKind::WatchLater, video) - .unwrap(); - let path = app.video_state.progress_file(video).unwrap(); - video_state::register_identity(&path, &video.id).unwrap(); - report(&path, &video.id, 1); - path - }) - .collect(); - app.update(); - rename_press(&mut app, KeyCode::Char('C')); - assert_eq!(app.views[app.view_index].videos.len(), 3); - let second = app.views[app.view_index] - .videos - .iter() - .position(|video| video.id == videos[1].id) - .unwrap(); - app.views[app.view_index].selection.select(Some(second)); - app.preview = Some(PreviewMode::Description); - report(&reports[0], &videos[0].id, 95); - app.update(); - let view = &app.views[app.view_index]; - assert_eq!(view.videos.len(), 2); - assert_eq!( - view.videos[view.selection.selected().unwrap()].id, - videos[1].id - ); - assert_eq!(app.preview, Some(PreviewMode::Description)); - report(&reports[1], &videos[1].id, 95); - app.update(); - assert_eq!(app.views[app.view_index].videos.len(), 1); - assert!(app.preview.is_none()); - assert!(app.status.contains("1 videos")); - rename_press(&mut app, KeyCode::Char('x')); - assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); - report(&reports[2], &videos[2].id, 2); - app.update(); - assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); - let fresh = app.video_state.progress_file(&videos[2]).unwrap(); - video_state::register_identity(&fresh, &videos[2].id).unwrap(); - report(&fresh, &videos[2].id, 1); - app.update(); - rename_press(&mut app, KeyCode::Char('X')); - assert_eq!( - app.collection_removal.as_ref().unwrap().ids, - [videos[2].id.clone()] - ); - rename_press(&mut app, KeyCode::Enter); - assert!(app.collection_removal.is_some()); - rename_press(&mut app, KeyCode::Char('y')); - assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 3); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3); - assert!(app.video_state.is_watched(&videos[0].id)); - assert!(app.video_state.is_watched(&videos[1].id)); - assert!(!app.video_state.is_watched(&videos[2].id)); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn direct_jumps_reuse_cached_views_and_navigation_with_configured_bindings() { - for (document, search, feed) in [ - ("", KeyCode::Char('/'), KeyCode::Char('F')), - ( - "[keys]\nview.search = 'f6'\nview.feed = 'f7'\n", - KeyCode::F(6), - KeyCode::F(7), - ), - ] { - let mut app = app(&["tubular", "global draft"]); - if !document.is_empty() { - let document = document.parse::().unwrap(); - app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); - } - let videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"# - ).unwrap().videos; - app.views.push(View::new( - "Temporary".into(), - ViewSource::Channel("creator".into()), - )); - for view in &mut app.views { - view.loaded = true; - view.videos = videos.clone(); - view.selection.select(Some(1)); - view.next_start = 61; - } - app.view_index = 2; - app.preview = Some(PreviewMode::Description); - rename_press(&mut app, feed); - assert_eq!(app.view_index, 1); - assert_eq!(app.mode, Mode::Normal); - assert!(app.preview.is_none()); - rename_press(&mut app, search); - assert_eq!(app.view_index, 0); - assert_eq!(app.views[0].query, "global draft"); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.view_index, 2); - rename_press(&mut app, KeyCode::Char('+')); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Char('+')); - assert_eq!(app.view_index, 0); - for view in &app.views { - assert_eq!(view.videos[1].id, "l-GaaivVJCE"); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.next_start, 61); - } - assert!(app.job.is_none()); - for source in [ - ViewSource::Channel("creator".into()), - ViewSource::SavedVideos(None), - ViewSource::WatchLater(None), - ViewSource::Watching, - ] { - app.views[2].source = source; - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.view_index = 2; - app.preview = preview; - rename_press(&mut app, search); - assert_eq!(app.view_index, 0); - assert_eq!(app.mode, Mode::Normal); - assert!(app.preview.is_none()); - rename_press(&mut app, feed); - assert_eq!(app.view_index, 1); - } - } - app.view_index = 0; - if !document.is_empty() { - app.view_index = 2; - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.view_index, 2, "remapping replaces both defaults"); - } - let document = "[keys]\nview.search = []\nview.feed = []\n" - .parse::() - .unwrap(); - app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); - app.view_index = 2; - rename_press(&mut app, search); - rename_press(&mut app, feed); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.view_index, 2); - assert!(app.job.is_none()); - } - } - - #[test] - fn direct_jump_keys_remain_literal_or_inert_in_modals() { - let mut app = app(&["tubular"]); - app.views[1].loaded = true; - app.view_index = 1; - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.views[0].query, "/F"); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Esc); - for kind in [BrowserKind::Categories, BrowserKind::Channels] { - app.open_browser(kind); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.browser.as_ref().unwrap().query, "/F"); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Esc); - } - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@creator".into(), - channel: "creator".into(), - }, - "Creator".into(), - Default::default(), - )); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.category_dialog.as_ref().unwrap().query, "/F"); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Char('n')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("/F") - ); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Char('?')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert!(app.help_popup.is_some()); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Esc); - app.collection_removal = Some(CollectionRemoval { - kind: CollectionKind::Saved, - ids: Vec::new(), - category: None, - filter: None, - text: "Confirm removal".into(), - }); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert!(app.collection_removal.is_some()); - assert_eq!(app.view_index, 1); - rename_press(&mut app, KeyCode::Esc); - let mut group = View::new("Combined".into(), ViewSource::CategoryGroup(vec![])); - group.loaded = true; - app.views.push(group); - let group_index = app.views.len() - 1; - app.view_index = group_index; - rename_press(&mut app, KeyCode::Char('P')); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('F')); - assert_eq!(app.category_name_input.as_ref().unwrap().name, "/F"); - assert_eq!(app.view_index, group_index); - } - - #[test] - fn category_and_group_queries_own_drafts_pages_and_errors_without_touching_search() { - for source in [ - ViewSource::Category("Alpha".into()), - ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]), - ] { - let mut app = app(&["tubular", "global draft"]); - app.views[0].submitted_query = "global submitted".into(); - app.views[0].loaded = true; - app.views[0].next_start = 61; - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"l-GaaivVJCE","title":"Global cached result"}]}"#, - ) - .unwrap() - .videos; - app.views[0].selection.select(Some(0)); - app.apply_config(config::Config { - categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), - ..Default::default() - }); - app.browse_view("Scoped creators".into(), source.clone()); - let index = app.view_index; - let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached"}]}"#; - app.views[index].feed_cursor.plan(1).unwrap(); - app.views[index] - .accept_page(feed_fixture(output).unwrap(), true) - .unwrap(); - rename_press(&mut app, KeyCode::Char('i')); - app.paste(" robotics 日本語 "); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.views[index].submitted_query, "robotics 日本語"); - assert_eq!(app.view_index, index); - assert_eq!(app.mode, Mode::Normal); - assert!(app.views[index].videos.is_empty()); - assert_eq!(app.views[index].selection.selected(), None); - assert_eq!(app.views[index].next_start, 1); - assert_eq!(app.views[index].feed_cursor.plan(1).unwrap()[0].start, 1); - app.finish_page(Err("Scoped request failed".into())); - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.view_index, index); - assert!(app.views[index].page_error.is_some()); - assert!( - app.job.is_none(), - "repeated failed query must not retry globally" - ); - app.views[index] - .accept_page(feed_fixture(output).unwrap(), true) - .unwrap(); - let next_start = app.views[index].next_start; - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.views[index].videos[0].id, "jNQXAC9IVRw"); - assert_eq!(app.views[index].next_start, next_start); - rename_press(&mut app, KeyCode::Char('i')); - app.paste(" draft"); - rename_press(&mut app, KeyCode::Esc); - assert_eq!(app.views[index].submitted_query, "robotics 日本語"); - assert!(app.views[index].query.ends_with(" draft")); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); - assert!(text.contains("Category search")); - assert!(!text.contains("YouTube search")); - rename_press(&mut app, KeyCode::Char('/')); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.views[app.view_index].source, source); - assert!(app.views[index].query.ends_with(" draft")); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.view_index, index); - assert!(app.views[index].submitted_query.is_empty()); - assert!(app.views[index].videos.is_empty()); - assert_eq!(app.views[index].next_start, 1); - assert!(app.job.is_none()); - assert_eq!(app.views[0].query, "global draft"); - assert_eq!(app.views[0].submitted_query, "global submitted"); - assert_eq!(app.views[0].next_start, 61); - assert_eq!(app.views[0].videos[0].id, "l-GaaivVJCE"); - assert_eq!(app.views[0].selection.selected(), Some(0)); - } - } - - #[test] - fn other_sources_submit_to_general_search_and_blank_global_query_is_a_noop() { - let mut app = app(&["tubular", " cached global "]); - app.views[0].submitted_query = "cached global".into(); - app.views[0].loaded = true; - app.views[0].next_start = 61; - for source in [ - ViewSource::Feed, - ViewSource::Channel("creator".into()), - ViewSource::WatchLater(None), - ViewSource::SavedVideos(None), - ] { - let mut view = View::new("Other".into(), source); - view.loaded = true; - app.views.push(view); - app.view_index = app.views.len() - 1; - rename_press(&mut app, KeyCode::Char('i')); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.view_index, 0); - assert_eq!(app.views[0].next_start, 61); - assert!(app.job.is_none()); - } - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - rename_press(&mut app, KeyCode::Enter); - assert!(app.views[0].query.is_empty()); - assert_eq!(app.views[0].submitted_query, "cached global"); - assert_eq!(app.views[0].next_start, 61); - assert!(app.job.is_none()); - } - - // Unrelated UI fixtures still parse real feed envelopes with a shared-budget plan. - fn feed_fixture(output: &str) -> Result { - let channels = serde_json::Deserializer::from_str(output) - .into_iter::() - .count(); - let requests = youtube::FeedCursor::default().plan(channels).unwrap(); - youtube::feed_results(output, &requests) - } - - fn finish_feed_fixture(app: &mut App, output: &str, full: bool) { - let view = &mut app.views[app.view_index]; - let channels = view - .source - .channels(&app.config, &app.feed_channels, &view.group_channels) - .len(); - view.feed_requests = view.feed_cursor.plan(channels).unwrap(); - let mut envelopes: Vec<_> = serde_json::Deserializer::from_str(output) - .into_iter::() - .map(Result::unwrap) - .collect(); - assert!(envelopes.len() <= view.feed_requests.len()); - envelopes.resize_with( - view.feed_requests.len(), - || serde_json::json!({"entries":[]}), - ); - if full { - for (envelope, request) in envelopes.iter_mut().zip(&view.feed_requests) { - let entries = envelope["entries"].as_array_mut().unwrap(); - assert!(entries.len() <= request.count); - entries.resize(request.count, serde_json::Value::Null); - } - } - let output = envelopes - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n"); - app.finish_page(Ok(output)); - } - - fn choose_combined(app: &mut App, key: KeyEvent) { - app.key(key); - while app - .browser - .as_ref() - .unwrap() - .selection - .selected() - .unwrap_or(0) - != 0 - { - app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - } - - fn rename_app(label: &str, keys: &str) -> App { - let root = - std::env::temp_dir().join(format!("tubular-rename-{label}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("config.toml"); - std::fs::write(&path, format!( - "[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@Alpha']\nOther = []\n{keys}" - )).unwrap(); - App::new( - Options::parse_from(["tubular"]), - path.clone(), - config::load(&path).unwrap(), - ) - } - - fn rename_press(app: &mut App, code: KeyCode) { - app.key(KeyEvent::new(code, KeyModifiers::NONE)); - } - - fn rename_to(app: &mut App, name: &str) { - app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - app.paste(name); - rename_press(app, KeyCode::Enter); - assert!(app.category_name_input.is_none()); - } - - fn group_app(label: &str, keys: &str) -> App { - let mut app = rename_app(&format!("group-{label}"), keys); - let config_text = |members: &str| { - format!( - "# keep this comment\n[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Alias' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Beta' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n{members}\n{keys}" - ) - }; - std::fs::write( - &app.config_path, - config_text("Alpha = []\nBeta = []\nGamma = []"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Down); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Enter); - std::fs::write( - &app.config_path, - config_text("Alpha = ['@Alpha']\nBeta = ['@Alias', '@Beta']\nGamma = []"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - app.views[app.view_index].loaded = true; - app - } - - #[test] - fn category_checks_survive_filters_and_enter_uses_zero_one_or_many_choices() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [ - ("Alpha".into(), vec![]), - ("Beta".into(), vec![]), - ("Gamma".into(), vec![]), - ] - .into(), - ..Default::default() - }); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Char('i')); - app.paste("beta"); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - app.paste("no match"); - assert!(app.browser.as_ref().unwrap().matches.is_empty()); - rename_press(&mut app, KeyCode::Enter); - let combined = app.views[app.view_index].source.clone(); - assert_eq!( - combined, - ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]) - ); - rename_press(&mut app, KeyCode::Char('c')); - assert_eq!( - app.browser - .as_ref() - .unwrap() - .checked - .iter() - .map(String::as_str) - .collect::>(), - ["Alpha", "Beta"] - ); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Enter); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Beta".into()) - ); - - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Down); - rename_press(&mut app, KeyCode::Enter); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Gamma".into()) - ); - let before = app.views[app.view_index].source.clone(); - let history = app.view_history.clone(); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Esc); - assert_eq!(app.views[app.view_index].source, before); - assert_eq!(app.view_history, history); - rename_press(&mut app, KeyCode::Char('c')); - assert!(app.browser.as_ref().unwrap().checked.is_empty()); - rename_press(&mut app, KeyCode::Char('i')); - app.paste("no match"); - rename_press(&mut app, KeyCode::Enter); - assert!(app.browser.is_some()); - assert_eq!(app.views[app.view_index].source, before); - } - - #[test] - fn combined_feed_deduplicates_overlapping_rows_and_restores_pages_through_history() { - let mut app = group_app("pages", ""); - let group = app.views[app.view_index].source.clone(); - assert_eq!( - app.views[app.view_index].group_channels, - [ - "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", - "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA", - ] - ); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared","timestamp":300},{"id":"l-GaaivVJCE","title":"Old","timestamp":100}]} -{"entries":[{"id":"jNQXAC9IVRw","title":"Shared again","timestamp":300},{"id":"rQ_J9WH6CGk","title":"Middle","timestamp":200}]}"#, - true, - ); - assert_eq!( - app.views[app.view_index] - .videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE"] - ); - rename_press(&mut app, KeyCode::Down); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"l-GaaivVJCE","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Last","timestamp":50}]}"#, - false, - ); - let next_start = app.views[app.view_index].next_start; - let names = vec!["Alpha".into(), "Gamma".into()]; - let mut replacement = View::new(group_name(&names), ViewSource::CategoryGroup(names)); - replacement.group_channels = app.config.categories["Alpha"].clone(); - replacement.loaded = true; - app.views.push(replacement); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Down); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Down); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Enter); - assert_eq!( - app.views[app.view_index].source, - ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()]) - ); - assert_eq!( - app.visible_views() - .filter(|(_, view)| matches!(view.source, ViewSource::CategoryGroup(_))) - .count(), - 1 - ); - rename_press(&mut app, KeyCode::Char('-')); - let view = &app.views[app.view_index]; - assert_eq!(view.source, group); - assert_eq!(view.videos.len(), 4); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.next_start, next_start); - assert!(app.job.is_none()); - rename_press(&mut app, KeyCode::Char('+')); - assert_eq!( - app.views[app.view_index].source, - ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()]) - ); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn combined_cache_resets_only_for_changed_resolved_union_and_drops_deleted_members() { - let mut app = group_app("config", ""); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, - false, - ); - let group = app.views[app.view_index].source.clone(); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - std::fs::write( - &app.config_path, - original - .replace("Alpha = ['@Alpha']", "Alpha = ['@Beta']") - .replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alpha']"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - let view = &app.views[app.view_index]; - assert_eq!(view.source, group); - assert_eq!(view.videos.len(), 1); - assert_eq!(view.next_start, PAGE_SIZE + 1); - std::fs::write( - &app.config_path, - original.replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alias']"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - let view = &app.views[app.view_index]; - assert_eq!(view.source, group); - assert_eq!(view.videos[0].title, "Cached"); - assert!(!view.loaded); - assert_eq!(view.next_start, 1); - assert_eq!(view.group_channels.len(), 1); - app.views[app.view_index].loaded = true; - app.views[0].loaded = true; - app.switch_view(0); - std::fs::write( - &app.config_path, - original.replace("Beta = ['@Alias', '@Beta']\n", ""), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - assert!(!app.views.iter().any(|view| view.source == group)); - assert!(app.temporary_view.is_none()); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn checked_category_rename_preserves_canonical_group_pin_selection_and_history() { - let mut app = group_app("rename", ""); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, - false, - ); - app.views[app.view_index].query = "draft astronomy".into(); - app.views[app.view_index].submitted_query = "astronomy".into(); - let old = app.views[app.view_index].source.clone(); - app.view_history.push(old.clone()); - app.forward_history.push(old); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Char('i')); - app.paste("Alpha"); - rename_press(&mut app, KeyCode::Esc); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - std::fs::write(&app.config_path, format!("{original}Extra = []\n")).unwrap(); - rename_press(&mut app, KeyCode::Char('p')); - assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2); - assert!(app.browser.as_ref().unwrap().checked.contains("Alpha")); - assert!(!app.config.pinned_categories.contains(&"Alpha".into())); - rename_press(&mut app, KeyCode::Char('p')); - rename_to(&mut app, "Zeta"); - let renamed = ViewSource::CategoryGroup(vec!["Beta".into(), "Zeta".into()]); - assert_eq!(app.views[app.view_index].source, renamed); - assert_eq!(app.temporary_view, Some(renamed.clone())); - assert!(app.view_history.contains(&renamed)); - assert!(app.forward_history.contains(&renamed)); - assert_eq!(app.views[app.view_index].videos.len(), 1); - assert_eq!(app.views[app.view_index].query, "draft astronomy"); - assert_eq!(app.views[app.view_index].submitted_query, "astronomy"); - assert_eq!(app.config.pinned_categories, ["Zeta"]); - let browser = app.browser.as_ref().unwrap(); - assert_eq!( - browser - .checked - .iter() - .map(String::as_str) - .collect::>(), - ["Beta", "Zeta"] - ); - assert_eq!(browser.selected_choice().unwrap().0, "Zeta"); - rename_press(&mut app, KeyCode::Enter); - assert_eq!(app.views[app.view_index].source, renamed); - assert!(app.job.is_none()); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn saving_combined_view_copies_snapshot_and_preserves_cached_state_without_pinning() { - let mut app = group_app("save", ""); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":200},{"id":"l-GaaivVJCE","title":"Second","timestamp":100}]}"#, - true, - ); - rename_press(&mut app, KeyCode::Down); - let source = app.views[app.view_index].source.clone(); - let channels = app.views[app.view_index].group_channels.clone(); - let next_requests = app.views[app.view_index] - .feed_cursor - .plan(channels.len()) - .unwrap(); - app.views[app.view_index].feed_requests = next_requests.clone(); - app.views[app.view_index].prefetch_requested = true; - app.views[app.view_index].has_more = true; - app.views[app.view_index].page_error = Some("keep failure".into()); - app.views[app.view_index].query = "draft physics".into(); - app.views[app.view_index].submitted_query = "physics".into(); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - rename_press(&mut app, KeyCode::Char('P')); - app.paste("Saved union"); - rename_press(&mut app, KeyCode::Esc); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - assert_eq!(app.views[app.view_index].source, source); - for invalid in [" ", "Alpha", "Bad\nname"] { - rename_press(&mut app, KeyCode::Char('P')); - app.category_name_input.as_mut().unwrap().name = invalid.into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - assert_eq!(app.views[app.view_index].source, source); - rename_press(&mut app, KeyCode::Esc); - } - rename_press(&mut app, KeyCode::Char('P')); - app.paste("OnDisk"); - let external = format!("{original}OnDisk = []\n"); - std::fs::write(&app.config_path, &external).unwrap(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external); - assert_eq!(app.views[app.view_index].source, source); - rename_press(&mut app, KeyCode::Esc); - std::fs::write(&app.config_path, &original).unwrap(); - rename_press(&mut app, KeyCode::Char('P')); - app.paste(" Saved union "); - // External edits do not change the union being saved. - std::fs::write( - &app.config_path, - original.replace("Alpha = ['@Alpha']", "Alpha = []"), - ) - .unwrap(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.is_none()); - let view = &app.views[app.view_index]; - assert_eq!(view.source, ViewSource::Category("Saved union".into())); - assert_eq!(view.query, "draft physics"); - assert_eq!(view.submitted_query, "physics"); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.videos.len(), 2); - assert_eq!(view.next_start, PAGE_SIZE + 1); - assert_eq!(view.feed_requests, next_requests); - assert_eq!( - view.feed_cursor.clone().plan(channels.len()).unwrap(), - next_requests - ); - assert!(view.prefetch_requested); - assert!(view.has_more); - assert_eq!(view.page_error.as_deref(), Some("keep failure")); - assert!(app.job.is_none()); - assert_eq!(app.config.categories["Saved union"], channels); - assert_eq!(app.config.categories["Beta"].len(), 2); - assert!(app.config.categories["Alpha"].is_empty()); - assert_eq!(app.config.pinned_categories, ["Alpha"]); - assert_eq!( - app.temporary_view, - Some(ViewSource::Category("Saved union".into())) - ); - let disk = std::fs::read_to_string(&app.config_path) - .unwrap() - .parse::() - .unwrap(); - assert_eq!( - disk["categories"]["Saved union"] - .as_array() - .unwrap() - .iter() - .map(|value| value.as_str().unwrap()) - .collect::>(), - ["@Beta", "@Alias"] - ); - assert!( - std::fs::read_to_string(&app.config_path) - .unwrap() - .starts_with("# keep this comment") - ); - rename_press(&mut app, KeyCode::Char('-')); - assert_eq!(app.views[app.view_index].source, source); - assert_eq!(app.views[app.view_index].group_channels, channels); - let saved = std::fs::read_to_string(&app.config_path).unwrap(); - std::fs::write( - &app.config_path, - saved.replace("Beta = ['@Alias', '@Beta']", "Beta = []"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - assert!(app.views[app.view_index].group_channels.is_empty()); - assert_eq!(app.views[app.view_index].query, "draft physics"); - assert_eq!(app.views[app.view_index].submitted_query, "physics"); - assert_eq!(app.config.categories["Saved union"], channels); - app.forward_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Saved union".into()) - ); - assert_eq!(app.views[app.view_index].videos.len(), 2); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.views[app.view_index].submitted_query, "physics"); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn configurable_group_controls_share_text_editing_and_are_inert_outside_groups() { - let keys = "[keys]\nselector.toggle = 'f5'\ncategory.save = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n"; - let mut app = group_app("remapped", ""); - let doc = keys.parse::().unwrap(); - app.config.keys = keys::Bindings::from_toml(doc.get("keys")).unwrap(); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - rename_press(&mut app, KeyCode::Char('c')); - rename_press(&mut app, KeyCode::Char(' ')); - assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2); - rename_press(&mut app, KeyCode::F(5)); - assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SHIFT)); - app.paste("P R"); - assert_eq!(app.browser.as_ref().unwrap().query, " P R"); - assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1); - rename_press(&mut app, KeyCode::F(8)); - rename_press(&mut app, KeyCode::Esc); - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = preview; - rename_press(&mut app, KeyCode::Char('P')); - assert!(app.category_name_input.is_none()); - rename_press(&mut app, KeyCode::F(6)); - assert!(app.category_name_input.is_some()); - rename_press(&mut app, KeyCode::F(9)); - app.paste("日本語 words"); - rename_press(&mut app, KeyCode::F(10)); - rename_press(&mut app, KeyCode::F(11)); - app.key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT)); - app.paste("\nR\t"); - assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語P R "); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.is_some()); - rename_press(&mut app, KeyCode::F(8)); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - } - app.views[0].loaded = true; - app.switch_view(0); - rename_press(&mut app, KeyCode::F(6)); - assert!(app.category_name_input.is_none()); - for kind in [ - BrowserKind::Channels, - BrowserKind::WatchLater, - BrowserKind::SavedVideos, - ] { - app.open_browser(kind); - rename_press(&mut app, KeyCode::F(5)); - rename_press(&mut app, KeyCode::F(6)); - assert!(app.browser.as_ref().unwrap().checked.is_empty()); - assert!(app.category_name_input.is_none()); - rename_press(&mut app, KeyCode::Esc); - } - app.back_view(); - rename_press(&mut app, KeyCode::F(6)); - app.paste("Remapped"); - rename_press(&mut app, KeyCode::F(7)); - assert!(app.category_name_input.is_none()); - assert!(app.config.categories.contains_key("Remapped")); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn combined_sources_do_not_infer_a_category_for_scoped_actions() { - let mut app = group_app("scopes", ""); - let source = app.views[app.view_index].source.clone(); - for key in [ - KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT), - KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), - KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), - ] { - app.key(key); - assert_eq!(app.views[app.view_index].source, source); - assert!(app.category_dialog.is_none()); - assert!(app.category_name_input.is_none()); - assert!(app.browser.is_none()); - } - for key in ['W', 'V'] { - rename_press(&mut app, KeyCode::Char(key)); - assert!(app.browser.is_some()); - assert!(app.browser.as_ref().unwrap().checked.is_empty()); - rename_press(&mut app, KeyCode::Esc); - assert_eq!(app.views[app.view_index].source, source); - } - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn empty_combined_view_can_be_saved_as_an_empty_category() { - let mut app = group_app("empty-save", ""); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - std::fs::write( - &app.config_path, - original - .replace("Alpha = ['@Alpha']", "Alpha = []") - .replace("Beta = ['@Alias', '@Beta']", "Beta = []"), - ) - .unwrap(); - app.apply_config(config::load(&app.config_path).unwrap()); - rename_press(&mut app, KeyCode::Char('P')); - app.paste("Empty union"); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.is_none()); - assert!(app.config.categories["Empty union"].is_empty()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Empty union".into()) - ); - assert!(app.views[app.view_index].loaded); - assert!(app.views[app.view_index].videos.is_empty()); - assert!(app.job.is_none()); - assert_eq!(app.config.pinned_categories, ["Alpha"]); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn category_checkboxes_and_dim_pins_remain_distinct_when_highlighted() { - use ratatui::{Terminal, backend::TestBackend, style::Modifier}; - let mut app = group_app("markers", ""); - rename_press(&mut app, KeyCode::Char('c')); - let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let row = (0..buffer.area.height) - .find_map(|y| { - let text: String = (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect(); - text.find("[x] Alpha p") - .map(|byte| (text[..byte].chars().count() as u16, y)) - }) - .expect("checked and pinned category row"); - assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM)); - rename_press(&mut app, KeyCode::Char(' ')); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let text: String = (0..buffer.area.width) - .map(|x| buffer[(x, row.1)].symbol()) - .collect(); - assert!(text.contains("[ ] Alpha p")); - assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM)); - assert!(app.config.pinned_categories.contains(&"Alpha".into())); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn category_rename_preserves_pins_cached_pages_collection_scopes_and_history() { - let mut app = rename_app("cache", ""); - let videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"# - ).unwrap().videos; - for source in [ - ViewSource::WatchLater(Some("Alpha".into())), - ViewSource::SavedVideos(Some("Alpha".into())), - ] { - let mut view = View::new("Alpha collection".into(), source); - view.videos = videos.clone(); - view.selection.select(Some(1)); - view.loaded = true; - view.watch_filter = WatchFilter::Watched; - app.views.push(view); - } - app.view_index = 2; - let view = &mut app.views[2]; - view.query = " draft robotics ".into(); - view.submitted_query = "robotics".into(); - view.videos = videos.clone(); - view.feed_requests = view.feed_cursor.plan(1).unwrap(); - view.prefetch_requested = true; - view.selection.select(Some(1)); - view.loaded = true; - view.next_start = 41; - view.has_more = true; - view.page_error = Some("keep page failure".into()); - app.temporary_view = Some(ViewSource::SavedVideos(Some("Alpha".into()))); - app.view_history = vec![ViewSource::WatchLater(Some("Alpha".into()))]; - app.forward_history = vec![ViewSource::SavedVideos(Some("Alpha".into()))]; - rename_to(&mut app, "Zeta"); - let view = &app.views[app.view_index]; - assert_eq!(view.source, ViewSource::Category("Zeta".into())); - assert_eq!(view.name, "Zeta"); - assert_eq!(view.query, " draft robotics "); - assert_eq!(view.submitted_query, "robotics"); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.videos.len(), 2); - assert_eq!(view.feed_requests.len(), 1); - assert!(view.prefetch_requested); - assert_eq!(view.next_start, 41); - assert!(view.loaded && view.has_more); - assert_eq!(view.page_error.as_deref(), Some("keep page failure")); - assert_eq!(app.config.pinned_categories, ["Zeta"]); - assert_eq!(app.views[2].source, ViewSource::Category("Other".into())); - assert_eq!( - app.temporary_view, - Some(ViewSource::SavedVideos(Some("Zeta".into()))) - ); - assert_eq!( - app.view_history, - [ViewSource::WatchLater(Some("Zeta".into()))] - ); - assert_eq!( - app.forward_history, - [ViewSource::SavedVideos(Some("Zeta".into()))] - ); - for view in &app.views { - if matches!( - view.source, - ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) - ) { - assert_eq!(view.source.category(), Some("Zeta")); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.watch_filter, WatchFilter::Watched); - assert_eq!(view.videos.len(), 2); - } - } - app.back_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::WatchLater(Some("Zeta".into())) - ); - app.forward_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Zeta".into()) - ); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn rename_selectors_keep_renamed_match_selected_and_ignore_noncategory_rows() { - for (label, kind) in [ - ("categories", BrowserKind::Categories), - ("later", BrowserKind::WatchLater), - ("saved", BrowserKind::SavedVideos), - ("channels", BrowserKind::Channels), - ] { - let mut app = rename_app(label, ""); - app.open_browser(kind); - if kind != BrowserKind::Categories { - rename_press(&mut app, KeyCode::Char('R')); - assert!(app.category_name_input.is_none()); - } - if kind == BrowserKind::Channels { - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - continue; - } - let browser = app.browser.as_mut().unwrap(); - browser.query = "alp".into(); - browser.refilter(); - rename_press(&mut app, KeyCode::Char('R')); - rename_press(&mut app, KeyCode::Esc); - assert_eq!(app.browser.as_ref().unwrap().query, "alp"); - rename_to(&mut app, "Zeta"); - let browser = app.browser.as_ref().unwrap(); - assert!(browser.query.is_empty()); - let selected = browser.matches[browser.selection.selected().unwrap()]; - assert_eq!(browser.choices[selected].1.category(), Some("Zeta")); - assert!(app.config.pinned_categories.contains(&"Zeta".into())); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - for (label, source) in [ - ( - "later-current", - ViewSource::WatchLater(Some("Alpha".into())), - ), - ( - "saved-current", - ViewSource::SavedVideos(Some("Alpha".into())), - ), - ] { - let mut app = rename_app(label, ""); - let mut view = View::new("Alpha collection".into(), source); - view.loaded = true; - app.views.push(view); - app.view_index = app.views.len() - 1; - rename_to(&mut app, "Zeta"); - assert_eq!(app.views[app.view_index].source.category(), Some("Zeta")); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - } - - #[test] - fn rename_validation_keeps_prompt_and_disk_unchanged_including_external_edits() { - let mut app = rename_app("invalid", ""); - app.view_index = 2; - let original = std::fs::read_to_string(&app.config_path).unwrap(); - for name in ["", " ", "Other", "bad\nname"] { - rename_press(&mut app, KeyCode::Char('R')); - app.category_name_input.as_mut().unwrap().name = name.into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Alpha".into()) - ); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - rename_press(&mut app, KeyCode::Esc); - } - rename_to(&mut app, "Alpha"); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - for external in [ - original.replace("Alpha = ['@Alpha']", "Alpha = []"), - original.replace("Alpha = ['@Alpha']", "Removed = ['@Alpha']"), - original.replace("Other = []", "Other = []\nZeta = []"), - "not valid toml = [".into(), - ] { - std::fs::write(&app.config_path, &external).unwrap(); - rename_press(&mut app, KeyCode::Char('R')); - app.category_name_input.as_mut().unwrap().name = "Zeta".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external); - assert!(app.config.categories.contains_key("Alpha")); - rename_press(&mut app, KeyCode::Esc); - } - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn tag_rename_preserves_staging_retargets_bulk_source_and_saves_only_on_tag_submit() { - for bulk in [false, true] { - let mut app = rename_app(if bulk { "bulk" } else { "single" }, ""); - app.view_index = 2; - let channel = app.config.categories["Alpha"][0].clone(); - if bulk { - app.open_bulk_category_picker(); - } else { - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel: channel.clone(), - }, - " @Alpha ".into(), - [("Alpha".into(), true), ("Other".into(), false)].into(), - )); - } - let dialog = app.category_dialog.as_mut().unwrap(); - dialog.tags.insert("Staged".into(), true); - dialog.tags.insert("Unchecked".into(), false); - dialog.tags.insert("Other".into(), true); - dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect(); - dialog.query = "Alpha".into(); - dialog.refilter(); - rename_to(&mut app, "Zeta"); - let disk = config::load(&app.config_path).unwrap(); - assert_eq!(disk.categories["Zeta"], std::slice::from_ref(&channel)); - assert!(disk.categories["Other"].is_empty()); - assert!(!disk.categories.contains_key("Staged")); - let dialog = app.category_dialog.as_ref().unwrap(); - assert!(dialog.tags["Zeta"] && dialog.tags["Other"] && dialog.tags["Staged"]); - assert!(!dialog.tags["Unchecked"]); - if let CategoryTarget::Category { name, channels } = &dialog.target { - assert_eq!(name, "Zeta"); - assert_eq!(channels, std::slice::from_ref(&channel)); - } - rename_press(&mut app, KeyCode::Esc); - assert!(app.category_dialog.is_none()); - assert!(config::load(&app.config_path).unwrap().categories["Other"].is_empty()); - // Reopen with staged memberships and rename the bulk source a second time; - // saving must use the new identity, not the now-missing original key. - app.category_dialog = Some(CategoryDialog::new( - if bulk { - CategoryTarget::Category { - name: "Zeta".into(), - channels: vec![channel.clone()], - } - } else { - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel: channel.clone(), - } - }, - " target ".into(), - [ - ("Zeta".into(), true), - ("Other".into(), true), - ("Staged".into(), true), - ] - .into(), - )); - let dialog = app.category_dialog.as_mut().unwrap(); - dialog.query = "Zeta".into(); - dialog.refilter(); - rename_to(&mut app, "Beta"); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_dialog.is_none()); - let disk = config::load(&app.config_path).unwrap(); - for tag in ["Beta", "Other", "Staged"] { - assert_eq!(disk.categories[tag], std::slice::from_ref(&channel)); - } - assert!(!disk.categories.contains_key("Zeta")); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - } - - #[test] - fn new_tag_rename_is_local_and_rejects_staged_and_external_collisions() { - let mut app = rename_app("new-tag", ""); - let channel = app.config.categories["Alpha"][0].clone(); - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel, - }, - " @Alpha ".into(), - [ - ("Alpha".into(), true), - ("Other".into(), false), - ("Staged".into(), false), - ] - .into(), - )); - let dialog = app.category_dialog.as_mut().unwrap(); - dialog.query = "Staged".into(); - dialog.refilter(); - let original = std::fs::read_to_string(&app.config_path).unwrap(); - rename_press(&mut app, KeyCode::Char('R')); - app.category_name_input.as_mut().unwrap().name = "Other".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - app.category_name_input.as_mut().unwrap().name = "Alpha".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - std::fs::write(&app.config_path, format!("{original}OnDisk = []\n")).unwrap(); - app.category_name_input.as_mut().unwrap().name = "OnDisk".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - std::fs::write(&app.config_path, format!("{original}Staged = []\n")).unwrap(); - app.category_name_input.as_mut().unwrap().name = "Renamed new".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.as_ref().unwrap().error.is_some()); - std::fs::write(&app.config_path, &original).unwrap(); - app.category_name_input.as_mut().unwrap().name = "Renamed new".into(); - rename_press(&mut app, KeyCode::Enter); - assert!(app.category_name_input.is_none()); - assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); - let dialog = app.category_dialog.as_ref().unwrap(); - assert!(!dialog.tags["Renamed new"]); - assert!(!dialog.tags.contains_key("Staged")); - rename_press(&mut app, KeyCode::Char(' ')); - rename_press(&mut app, KeyCode::Enter); - assert!( - config::load(&app.config_path) - .unwrap() - .categories - .contains_key("Renamed new") - ); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn unpinned_category_rename_retains_its_temporary_tab_and_cached_rows() { - let mut app = rename_app("unpinned", ""); - let config = config::set_category_pinned(&app.config_path, "Alpha", false).unwrap(); - app.apply_config(config); - app.view_index = 2; - app.views[2].loaded = true; - app.views[2].next_start = 61; - app.temporary_view = Some(ViewSource::Category("Alpha".into())); - rename_to(&mut app, "Zeta"); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Zeta".into()) - ); - assert_eq!(app.views[app.view_index].next_start, 61); - assert_eq!( - app.temporary_view, - Some(ViewSource::Category("Zeta".into())) - ); - assert!(app.config.pinned_categories.is_empty()); - assert_eq!( - app.visible_views().last().unwrap().1.source, - ViewSource::Category("Zeta".into()) - ); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn rename_uses_configured_actions_and_uppercase_r_stays_literal_in_all_inputs() { - let keys = "[keys]\ncategory.rename = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n"; - for entry in ["view", "browser", "tags"] { - let mut app = rename_app(&format!("remapped-{entry}"), keys); - app.view_index = 2; - match entry { - "browser" => app.open_browser(BrowserKind::Categories), - "tags" => { - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel: app.config.categories["Alpha"][0].clone(), - }, - " target ".into(), - [("Alpha".into(), true), ("Other".into(), false)].into(), - )) - } - _ => {} - } - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - assert!( - app.category_name_input.is_none(), - "default action is replaced" - ); - rename_press(&mut app, KeyCode::F(6)); - assert_eq!(app.key_context(), Context::CategoryNameInput); - rename_press(&mut app, KeyCode::F(9)); - app.paste("日本語 words"); - rename_press(&mut app, KeyCode::F(10)); - assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語 "); - rename_press(&mut app, KeyCode::F(11)); - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - app.paste("\n?\t"); - assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語R ? "); - assert!(app.help_popup.is_none()); - rename_press(&mut app, KeyCode::F(8)); - assert!(app.category_name_input.is_none()); - assert!(app.config.categories.contains_key("Alpha")); - rename_press(&mut app, KeyCode::F(6)); - rename_press(&mut app, KeyCode::F(9)); - app.paste("Zeta"); - rename_press(&mut app, KeyCode::Enter); - assert!( - app.category_name_input.is_some(), - "default submit is replaced" - ); - rename_press(&mut app, KeyCode::F(7)); - assert!(app.category_name_input.is_none()); - assert!( - config::load(&app.config_path) - .unwrap() - .categories - .contains_key("Zeta") - ); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - let mut app = rename_app("literal-r", ""); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - assert_eq!(app.views[0].query, "R"); - assert!(app.category_name_input.is_none()); - rename_press(&mut app, KeyCode::Esc); - app.open_browser(BrowserKind::Categories); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - assert_eq!(app.browser.as_ref().unwrap().query, "R"); - assert!(app.category_name_input.is_none()); - app.browser = None; - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel: app.config.categories["Alpha"][0].clone(), - }, - " target ".into(), - [("Alpha".into(), true)].into(), - )); - rename_press(&mut app, KeyCode::Char('i')); - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - assert_eq!(app.category_dialog.as_ref().unwrap().query, "R"); - assert!(app.category_name_input.is_none()); - rename_press(&mut app, KeyCode::Esc); - rename_press(&mut app, KeyCode::Char('n')); - app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("R") - ); - assert!(app.category_name_input.is_none()); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn rename_prompt_cursor_scrolls_by_display_width_without_changing_selection() { - use ratatui::{Terminal, backend::TestBackend}; - let mut app = rename_app("rename-cursor", ""); - app.view_index = 2; - rename_press(&mut app, KeyCode::Char('R')); - let mut terminal = Terminal::new(TestBackend::new(24, 16)).unwrap(); - for (text, x) in [ - ("", 3), - ("日本語", 9), - ("e\u{301}", 4), - ("abcdefghijklmnopqrstuvwxyz", 20), - ] { - app.category_name_input.as_mut().unwrap().name = text.into(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), (x, 4).into()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Alpha".into()) - ); - } - rename_press(&mut app, KeyCode::Esc); - assert!(app.category_name_input.is_none()); - std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); - } - - #[test] - fn collection_removal_is_inert_in_ordinary_views_and_their_previews() { - let mut app = app(&["tubular"]); - app.config.categories.insert("Category".into(), vec![]); - let videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].videos = videos.clone(); - app.views[0].loaded = true; - for row in 0..2 { - app.views[0].selection.select(Some(row)); - app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); - } - for source in [ - ViewSource::Search, - ViewSource::Feed, - ViewSource::Category("Category".into()), - ViewSource::Channel(config::normalize_channel("@creator").unwrap()), - ] { - let mut view = View::new("Ordinary".into(), source); - view.videos = videos.clone(); - view.loaded = true; - view.selection.select(Some(0)); - let index = app.views.len(); - app.views.push(view); - app.switch_view(index); - for (preview, context) in [ - (None, Context::Normal), - (Some(PreviewMode::Info), Context::Info), - (Some(PreviewMode::Description), Context::Description), - ] { - app.preview = preview; - assert_eq!(app.key_context(), context); - assert!(app.current_collection().is_none()); - let status = app.status.clone(); - for key in ['x', 'X', 'D'] { - app.key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); - } - app.remove_collection(); - app.remove_collection_bulk(); - assert!(app.collection_removal.is_none()); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - assert_eq!(app.views[index].selection.selected(), Some(0)); - assert_eq!(app.preview, preview); - assert_eq!(app.status, status); - app.paste("xX"); - assert!(app.views[0].query.is_empty()); - } - } - } - - #[test] - fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_preview() { - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - for row in 0..2 { - app.views[0].selection.select(Some(row)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::Saved); - } - for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { - app.switch_view(0); - app.browse_collection(kind); - for (preview, context, cancel) in [ - (None, Context::CollectionNormal, KeyCode::Char('n')), - ( - Some(PreviewMode::Info), - Context::CollectionInfo, - KeyCode::Esc, - ), - ( - Some(PreviewMode::Description), - Context::CollectionDescription, - KeyCode::Char('n'), - ), - ] { - app.preview = preview; - assert_eq!(app.key_context(), context); - app.key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)); - assert!(app.collection_removal.is_none()); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - assert_eq!(app.preview, None); - let removal = app.collection_removal.as_ref().unwrap(); - assert_eq!(removal.ids.len(), 2); - assert!(removal.category.is_none()); - assert_eq!( - removal.filter, - matches!(kind, CollectionKind::WatchLater).then_some(WatchFilter::All) - ); - let history = app.view_history.clone(); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - app.paste("y"); - assert!(app.collection_removal.is_some()); - assert!(app.player_launch.is_none()); - assert_eq!(app.view_history, history); - assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); - assert!(app.collection_removal.is_some()); - app.key(KeyEvent::new(cancel, KeyModifiers::NONE)); - assert!(app.collection_removal.is_none()); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - } - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::SearchInput); - app.paste("xX"); - assert_eq!(app.views[0].query, "xX"); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::CollectionNormal); - app.views[0].query.clear(); - app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::Help); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - assert!(app.collection_removal.is_none()); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::CollectionNormal); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::SelectorNormal); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::SelectorInput); - app.paste("xX"); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::CollectionNormal); - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@creator".into(), - channel: config::normalize_channel("@creator").unwrap(), - }, - "Creator".into(), - [("Category".into(), false)].into(), - )); - assert_eq!(app.key_context(), Context::TagList); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::TagFilter); - app.paste("xX"); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::TagInput); - app.paste("xX"); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("xX") - ); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.key_context(), Context::CollectionNormal); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - } - } - - #[test] - fn configured_removal_hints_follow_collection_views_and_previews() { - let mut app = app(&["tubular"]); - let document = - "[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n" - .parse::() - .unwrap(); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"}]}"#, - ) - .unwrap() - .videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::Saved); - app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into()); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap(); - for kind in [ - None, - Some(CollectionKind::WatchLater), - Some(CollectionKind::Saved), - ] { - app.switch_view(0); - if let Some(kind) = kind { - app.browse_collection(kind); - } - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = preview; - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); - assert_eq!(rendered.contains("F8"), kind.is_some()); - assert_eq!(rendered.contains("F9"), kind.is_some()); - if preview == Some(PreviewMode::Info) { - let popup = preview_area(buffer.area); - let title: String = (popup.x..popup.right()) - .map(|x| buffer[(x, popup.y)].symbol()) - .collect(); - assert_eq!(title.contains("F8"), kind.is_some()); - assert_eq!(title.contains("F9"), kind.is_some()); - } - } - } - } - - #[test] - fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() { - let root = - std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join("watch-state.toml"), - "version = 1\nwatched = ['jNQXAC9IVRw']\n", - ) - .unwrap(); - let category_channels = vec![config::normalize_channel("@creator").unwrap()]; - let config = config::Config { - categories: [("Category".into(), category_channels.clone())].into(), - ..Default::default() - }; - let path = root.join("config.toml"); - let mut app = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config, - video_state::State::open(&path).unwrap(), - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Watched","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Unwatched","uploader_id":"@creator"},{"id":"l-GaaivVJCE","title":"Outside category","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - for row in 0..3 { - app.views[0].selection.select(Some(row)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::Saved); - } - app.views[2].loaded = true; - app.switch_view(2); - app.browse_category_collection(CollectionKind::Saved); - app.preview = Some(PreviewMode::Description); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)); - let removal = app.collection_removal.as_ref().unwrap(); - assert!(matches!(removal.kind, CollectionKind::Saved)); - assert_eq!(removal.category.as_deref(), Some("Category")); - assert_eq!(removal.filter, None); - assert_eq!(removal.ids, ["jNQXAC9IVRw", "rQ_J9WH6CGk"]); - let prompt = removal.text.clone(); - app.apply_config(config::Config { - categories: [( - "Category".into(), - vec![ - config::normalize_channel("@creator").unwrap(), - config::normalize_channel("@other").unwrap(), - ], - )] - .into(), - ..Default::default() - }); - assert_eq!(app.views[app.view_index].videos.len(), 3); - assert_eq!(app.collection_removal.as_ref().unwrap().text, prompt); - app.browse_category_collection(CollectionKind::WatchLater); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); - assert!( - app.video_state - .contains(CollectionKind::Saved, "l-GaaivVJCE") - ); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - app.apply_config(config::Config { - categories: [("Category".into(), category_channels)].into(), - ..Default::default() - }); - app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].videos.len(), 1); - app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - let removal = app.collection_removal.as_ref().unwrap(); - assert!(matches!(removal.kind, CollectionKind::WatchLater)); - assert_eq!(removal.filter, Some(WatchFilter::Watched)); - assert_eq!(removal.ids, ["jNQXAC9IVRw"]); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - assert!(app.views[app.view_index].videos.is_empty()); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - app.switch_view(0); - app.browse_collection(CollectionKind::WatchLater); - app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].watch_filter, - WatchFilter::Unwatched - ); - assert_eq!(app.views[app.view_index].videos.len(), 2); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - assert!(app.collection_removal.as_ref().unwrap().category.is_none()); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!( - app.video_state - .videos(CollectionKind::WatchLater) - .is_empty() - ); - app.browse_collection(CollectionKind::Saved); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.video_state.videos(CollectionKind::Saved).is_empty()); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - drop(app); - let restarted = video_state::State::open(&path).unwrap(); - assert!(restarted.videos(CollectionKind::Saved).is_empty()); - assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); - assert!(restarted.is_watched("jNQXAC9IVRw")); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn individual_watch_later_removal_keeps_siblings_saved_and_watched() { - let root = std::env::temp_dir().join(format!( - "tubular-single-watch-removal-{}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join("watch-state.toml"), - "version = 1\nwatched = ['jNQXAC9IVRw']\n", - ) - .unwrap(); - let mut app = App::with_state( - Options::parse_from(["tubular"]), - root.join("config.toml"), - config::Config::default(), - video_state::State::open(&root.join("config.toml")).unwrap(), - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::Saved); - app.views[0].selection.select(Some(1)); - app.save_collection(CollectionKind::WatchLater); - app.browse_collection(CollectionKind::WatchLater); - app.views[app.view_index].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert!( - !app.video_state - .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") - ); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "rQ_J9WH6CGk") - ); - assert!( - app.video_state - .contains(CollectionKind::Saved, "jNQXAC9IVRw") - ); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - assert!(app.collection_removal.is_none()); - app.views[app.view_index].selection.select(Some(0)); - app.preview = Some(PreviewMode::Info); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert!( - app.video_state - .videos(CollectionKind::WatchLater) - .is_empty() - ); - assert_eq!(app.preview, None); - app.switch_view(0); - app.views[0].selection.select(Some(1)); - app.save_collection(CollectionKind::WatchLater); - app.browse_collection(CollectionKind::WatchLater); - app.preview = Some(PreviewMode::Description); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert!( - app.video_state - .videos(CollectionKind::WatchLater) - .is_empty() - ); - assert_eq!(app.preview, None); - drop(app); - let restarted = video_state::State::open(&root.join("config.toml")).unwrap(); - assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); - assert!(restarted.contains(CollectionKind::Saved, "jNQXAC9IVRw")); - assert!(restarted.is_watched("jNQXAC9IVRw")); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn manual_status_updates_filtered_lists_and_closes_removed_previews() { - let mut app = app(&["tubular"]); - let document = "[keys]\nvideo.status.watching = 'f6'\nvideo.status.watched = 'f7'\n" - .parse::() - .unwrap(); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - for row in 0..2 { - app.views[0].selection.select(Some(row)); - app.save_collection(CollectionKind::WatchLater); - } - app.browse_collection(CollectionKind::WatchLater); - app.filter_watch_later(); - app.views[app.view_index].selection.select(Some(0)); - app.preview = Some(PreviewMode::Info); - app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk"); - assert_eq!(app.preview, None); - app.filter_watch_later(); - assert_eq!(app.views[app.view_index].videos[0].id, "jNQXAC9IVRw"); - app.preview = Some(PreviewMode::Description); - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - assert!(!app.video_state.is_watched("jNQXAC9IVRw")); - assert!(app.views[app.view_index].videos.is_empty()); - assert_eq!(app.views[app.view_index].selection.selected(), None); - assert_eq!(app.preview, None); - assert!( - app.video_state - .contains(CollectionKind::Watching, "jNQXAC9IVRw") - ); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") - ); - app.browse_collection(CollectionKind::Watching); - app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); - assert!(app.views[app.view_index].videos.is_empty()); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - } - - #[test] - fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() { - let root = std::env::temp_dir().join(format!( - "tubular-watch-ui-regression-{}", - std::process::id() - )); - let path = root.join("config.toml"); - let _ = std::fs::remove_dir_all(&root); - let state = video_state::State::open(&path).unwrap(); - let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); - let config = config::Config { - channels: [("@creator".into(), channel.clone())].into(), - categories: [("Category".into(), vec![channel.clone()])].into(), - ..Default::default() - }; - let mut app = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config, - state, - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::WatchLater); - app.views[0].selection.select(Some(1)); - app.save_collection(CollectionKind::WatchLater); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - app.views[0].selection.select(None); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let title = buffer - .content - .iter() - .position(|cell| cell.symbol() == "M" && cell.fg == Color::Magenta); - assert!(title.is_some(), "saved video titles use a distinct color"); - app.views[0].selection.select(Some(1)); - - // An empty loaded category avoids extraction while opening its derived offline list. - app.views[2].loaded = true; - app.switch_view(2); - app.browse_category_collection(CollectionKind::WatchLater); - let category_source = ViewSource::WatchLater(Some("Category".into())); - assert_eq!(app.views[app.view_index].source, category_source); - assert_eq!(app.views[app.view_index].videos.len(), 1); - app.browse_category_collection(CollectionKind::WatchLater); - assert_eq!(app.views[app.view_index].source, category_source); - assert!(app.job.is_none()); - - app.filter_watch_later(); - assert_eq!( - app.views[app.view_index].watch_filter, - WatchFilter::Unwatched - ); - assert_eq!(app.views[app.view_index].videos.len(), 1); - app.filter_watch_later(); - assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched); - assert!(app.views[app.view_index].videos.is_empty()); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - assert!(app.collection_removal.is_none()); - app.filter_watch_later(); - - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - let history = app.view_history.clone(); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - app.paste("y"); - assert!(app.collection_removal.is_some()); - assert!(app.player_launch.is_none()); - assert_eq!(app.view_history, history); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - assert!(app.collection_removal.is_none()); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); - - app.apply_config(config::Config { - channels: [("@creator".into(), channel.clone())].into(), - categories: [("Category".into(), vec![])].into(), - ..Default::default() - }); - assert_eq!(app.views[app.view_index].source, category_source); - assert!(app.views[app.view_index].videos.is_empty()); - app.apply_config(config::Config { - channels: [("@creator".into(), channel.clone())].into(), - categories: [("Category".into(), vec![channel])].into(), - ..Default::default() - }); - assert_eq!( - app.views[app.view_index].videos.len(), - 1, - "mapped handles follow canonical membership" - ); - app.apply_config(config::Config { - categories: [( - "Category".into(), - vec![config::normalize_channel("@creator").unwrap()], - )] - .into(), - ..Default::default() - }); - assert_eq!( - app.views[app.view_index].videos.len(), - 1, - "unmapped handles remain usable" - ); - - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - app.apply_config(config::Config { - categories: [( - "Category".into(), - vec![ - config::normalize_channel("@creator").unwrap(), - config::normalize_channel("@other").unwrap(), - ], - )] - .into(), - ..Default::default() - }); - assert_eq!(app.views[app.view_index].videos.len(), 2); - assert_eq!( - app.collection_removal.as_ref().unwrap().ids, - ["jNQXAC9IVRw"] - ); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 1); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "rQ_J9WH6CGk") - ); - assert_eq!(app.views[app.view_index].videos.len(), 1); - - app.switch_view(0); - app.browse_collection(CollectionKind::WatchLater); - assert_eq!( - app.views[app.view_index].source, - ViewSource::WatchLater(None) - ); - app.back_view(); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - app.forward_view(); - drop(app); - let mut restarted = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config::Config::default(), - video_state::State::open(&path).unwrap(), - ); - restarted.browse_collection(CollectionKind::WatchLater); - assert!(restarted.job.is_none()); - assert!(restarted.views[restarted.view_index].loaded); - assert_eq!( - restarted.views[restarted.view_index].videos[0].title, - "Other creator" - ); - restarted.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - restarted.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!( - restarted - .video_state - .videos(CollectionKind::WatchLater) - .is_empty() - ); - drop(restarted); - assert!( - video_state::State::open(&path) - .unwrap() - .videos(CollectionKind::WatchLater) - .is_empty() - ); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn saved_views_follow_current_categories_and_restore_offline_history() { - let root = std::env::temp_dir().join(format!("tubular-saved-ui-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - let path = root.join("config.toml"); - std::fs::write( - root.join("watch-state.toml"), - "version = 1\nwatched = [\"jNQXAC9IVRw\"]\n", - ) - .unwrap(); - let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); - let other = config::normalize_channel("@other").unwrap(); - let config = config::Config { - channels: [("@creator".into(), channel.clone())].into(), - categories: [ - ("Category".into(), vec![channel.clone(), other.clone()]), - ("Overlap".into(), vec![channel]), - ] - .into(), - ..Default::default() - }; - let mut app = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config, - video_state::State::open(&path).unwrap(), - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - for row in 0..2 { - app.views[0].selection.select(Some(row)); - app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - } - app.views[2].loaded = true; - app.switch_view(2); - app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); - let saved_category = ViewSource::SavedVideos(Some("Category".into())); - assert_eq!(app.views[app.view_index].source, saved_category); - assert_eq!(app.views[app.view_index].videos.len(), 2); - app.views[app.view_index].selection.select(Some(1)); - app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::WatchLater(Some("Category".into())) - ); - app.filter_watch_later(); - assert_eq!(app.views[app.view_index].videos.len(), 1); - assert!(app.views[app.view_index].title.contains("Unwatched")); - app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); - assert_eq!(app.views[app.view_index].source, saved_category); - assert_eq!( - app.views[app.view_index].videos.len(), - 2, - "saved lists ignore watch filters" - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - let mut config = config::Config { - channels: app.config.channels.clone(), - categories: app.config.categories.clone(), - ..Default::default() - }; - config.categories.insert("Category".into(), vec![other]); - app.apply_config(config); - assert_eq!(app.views[app.view_index].source, saved_category); - assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk"); - assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); - let watch = app - .views - .iter() - .find(|view| matches!(view.source, ViewSource::WatchLater(Some(_)))) - .unwrap(); - assert_eq!(watch.watch_filter, WatchFilter::Unwatched); - assert!(watch.title.contains("Unwatched")); - app.switch_view(0); - app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(None) - ); - assert_eq!( - app.views[app.view_index].videos.len(), - 2, - "overlapping categories do not duplicate videos" - ); - app.views[app.view_index].selection.select(Some(1)); - app.back_view(); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - app.back_view(); - assert_eq!(app.views[app.view_index].source, saved_category); - app.forward_view(); - app.forward_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(None) - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert!(app.job.is_none()); - app.views[app.view_index].selection.select(Some(0)); - app.preview = Some(PreviewMode::Info); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert!( - app.preview.is_none(), - "removing the selected row dismisses its preview" - ); - assert_eq!(app.views[app.view_index].videos.len(), 1); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") - ); - assert!(app.video_state.is_watched("jNQXAC9IVRw")); - let config = std::mem::take(&mut app.config); - drop(app); - let mut restarted = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config, - video_state::State::open(&path).unwrap(), - ); - restarted.browse_collection(CollectionKind::Saved); - let view = &restarted.views[restarted.view_index]; - assert!(view.loaded && !view.has_more); - assert_eq!(view.videos.len(), 1); - assert_eq!(view.videos[0].title, "Other creator"); - assert!(restarted.job.is_none()); - assert!(restarted.video_state.is_watched("jNQXAC9IVRw")); - assert_eq!( - restarted - .video_state - .videos(CollectionKind::WatchLater) - .len(), - 2 - ); - drop(restarted); - std::fs::remove_dir_all(root).unwrap(); - } - - #[test] - fn collection_shortcuts_separate_global_and_category_scopes_in_every_video_context() { - for custom in [false, true] { - let root = std::env::temp_dir().join(format!( - "tubular-collection-scopes-{}-{custom}", - std::process::id() - )); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - std::fs::write( - root.join("watch-state.toml"), - "version = 1\nwatched = ['l-GaaivVJCE']\n", - ) - .unwrap(); - let path = root.join("config.toml"); - let mut app = App::with_state( - Options::parse_from(["tubular"]), - path.clone(), - config::Config::default(), - video_state::State::open(&path).unwrap(), - ); - let bindings = if custom { - let document = "[keys]\nview.watch_later.browse = 'f5'\nview.saved.browse = 'f6'\nview.watch_later.category = 'f7'\nview.saved.category = 'f8'\n" - .parse::().unwrap(); - keys::Bindings::from_toml(document.get("keys")).unwrap() - } else { - keys::Bindings::default() - }; - app.apply_config(config::Config { - categories: [( - "Category".into(), - vec![config::normalize_channel("@creator").unwrap()], - )] - .into(), - keys: bindings, - ..Default::default() - }); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Category","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Watched category","uploader_id":"@creator"}]}"#, - ).unwrap().videos; - for view in &mut app.views { - view.loaded = true; - } - for row in 0..3 { - app.views[0].selection.select(Some(row)); - app.save_collection(CollectionKind::WatchLater); - app.save_collection(CollectionKind::Saved); - } - let key = |function, character, modifiers| { - KeyEvent::new( - if custom { - KeyCode::F(function) - } else { - KeyCode::Char(character) - }, - if custom { - KeyModifiers::NONE - } else { - modifiers - }, - ) - }; - let global_watch = key(5, 'W', KeyModifiers::SHIFT); - let global_saved = key(6, 'V', KeyModifiers::SHIFT); - let category_watch = key(7, 'w', KeyModifiers::ALT); - let category_saved = key(8, 'v', KeyModifiers::ALT); - for (selector, expected) in [ - ( - global_watch, - ViewSource::WatchLater(Some("Category".into())), - ), - ( - global_saved, - ViewSource::SavedVideos(Some("Category".into())), - ), - ] { - app.switch_view(0); - let history = app.view_history.clone(); - app.key(selector); - let browser = app.browser.as_ref().unwrap(); - assert!(matches!( - browser.choices[0].1, - ViewSource::WatchLater(None) | ViewSource::SavedVideos(None) - )); - app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - assert!(app.config.pinned_categories.is_empty()); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - for c in "zzzz".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.browser.as_ref().unwrap().matches.is_empty()); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - assert_eq!(app.view_history, history); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); - for c in "category".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - assert_eq!(app.browser.as_ref().unwrap().matches.len(), 1); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, expected); - assert_eq!(app.views[app.view_index].videos.len(), 2); - assert!(app.browser.is_none()); - } - // Seed a cached combined view with a different filter. - choose_combined(&mut app, global_watch); - assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::All); - app.switch_view(2); - app.key(category_watch); - app.filter_watch_later(); - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = preview; - choose_combined(&mut app, global_watch); - let view = &app.views[app.view_index]; - assert_eq!(view.source, ViewSource::WatchLater(None)); - assert_eq!(view.watch_filter, WatchFilter::Unwatched); - assert_eq!( - view.videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["jNQXAC9IVRw", "rQ_J9WH6CGk"] - ); - assert_eq!(app.preview, None); - let history = app.view_history.clone(); - app.preview = preview; - choose_combined(&mut app, global_watch); - assert_eq!(app.preview, None); - assert_eq!(app.view_history, history); - app.back_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::WatchLater(Some("Category".into())) - ); - assert_eq!( - app.views[app.view_index].watch_filter, - WatchFilter::Unwatched - ); - assert_eq!(app.views[app.view_index].videos.len(), 1); - } - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = preview; - app.key(category_saved); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(Some("Category".into())) - ); - assert_eq!(app.preview, None); - app.preview = preview; - choose_combined(&mut app, global_saved); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(None) - ); - assert_eq!(app.views[app.view_index].videos.len(), 3); - assert_eq!(app.preview, None); - app.back_view(); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(Some("Category".into())) - ); - app.preview = preview; - app.key(category_watch); - assert_eq!( - app.views[app.view_index].source, - ViewSource::WatchLater(Some("Category".into())) - ); - assert_eq!( - app.views[app.view_index].watch_filter, - WatchFilter::Unwatched - ); - assert_eq!(app.preview, None); - } - app.switch_view(0); - choose_combined(&mut app, global_watch); - assert_eq!( - app.views[app.view_index].watch_filter, - WatchFilter::Unwatched - ); - let mut channel = View::new( - "Creator".into(), - ViewSource::Channel(config::normalize_channel("@creator").unwrap()), - ); - channel.loaded = true; - app.views.push(channel); - for index in [0, 1, app.views.len() - 1] { - app.switch_view(index); - for key in [category_watch, category_saved] { - let history = app.view_history.clone(); - app.key(key); - assert_eq!(app.view_index, index); - assert_eq!(app.view_history, history); - } - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - for key in [global_watch, global_saved] { - app.preview = preview; - choose_combined(&mut app, key); - assert!(matches!( - app.views[app.view_index].source, - ViewSource::WatchLater(None) | ViewSource::SavedVideos(None) - )); - assert_eq!(app.preview, None); - app.back_view(); - assert_eq!(app.view_index, index); - } - } - } - for key in [global_watch, global_saved] { - choose_combined(&mut app, key); - let source = app.views[app.view_index].source.clone(); - for category_key in [category_watch, category_saved] { - app.key(category_key); - assert_eq!(app.views[app.view_index].source, source); - } - } - assert!(app.job.is_none()); - drop(app); - std::fs::remove_dir_all(root).unwrap(); - } - } - - #[test] - fn collection_shortcuts_leave_text_entry_bindings_unchanged() { - let mut app = app(&["tubular"]); - let type_keys = |app: &mut App| { - for character in ['W', 'V', 'w', 'v'] { - app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); - } - for character in ['w', 'v'] { - app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)); - } - assert_eq!(app.view_index, 0); - assert!(app.view_history.is_empty()); - }; - app.mode = Mode::Insert; - type_keys(&mut app); - assert_eq!(app.views[0].query, "WVwv"); - app.mode = Mode::Normal; - for kind in [BrowserKind::Categories, BrowserKind::Channels] { - app.open_browser(kind); - app.browser.as_mut().unwrap().mode = Mode::Insert; - type_keys(&mut app); - assert_eq!(app.browser.as_ref().unwrap().query, "WVwv"); - app.browser = None; - } - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@creator".into(), - channel: "creator".into(), - }, - "Creator".into(), - BTreeMap::new(), - )); - app.category_dialog.as_mut().unwrap().mode = Mode::Insert; - type_keys(&mut app); - assert_eq!(app.category_dialog.as_ref().unwrap().query, "WVwv"); - app.category_dialog.as_mut().unwrap().name = Some(String::new()); - type_keys(&mut app); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("WVwv") - ); - } - - #[test] - fn saved_actions_use_configured_keys_in_each_video_context_and_remove_only_selection() { - let mut app = app(&["tubular"]); - let document = "[keys]\nvideo.saved.save = 'f6'\nview.saved.browse = 'f7'\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n" - .parse::().unwrap(); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Missing creator"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(1)); - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = preview; - if !app - .video_state - .contains(CollectionKind::Saved, "jNQXAC9IVRw") - { - app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); - assert!( - !app.video_state - .contains(CollectionKind::Saved, "jNQXAC9IVRw") - ); - } - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - let status = app.status.clone(); - app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - assert_eq!(app.preview, preview); - assert_eq!(app.status, status); - } - for preview in [ - None, - Some(PreviewMode::Info), - Some(PreviewMode::Description), - ] { - app.preview = None; - app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let row = app.views[app.view_index] - .videos - .iter() - .position(|video| video.id == "jNQXAC9IVRw") - .unwrap(); - app.views[app.view_index].selection.select(Some(row)); - app.preview = preview; - assert!(matches!( - app.current_collection(), - Some(CollectionKind::Saved) - )); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); - assert!( - app.video_state - .contains(CollectionKind::Saved, "rQ_J9WH6CGk") - ); - assert!( - app.video_state - .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") - ); - assert_eq!(app.preview, None); - app.switch_view(0); - app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - } - app.preview = None; - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - app.views[0].selection.select(Some(2)); - app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); - assert!(app.status.contains("channel identity")); - assert!( - !app.video_state - .contains(CollectionKind::Saved, "l-GaaivVJCE") - ); - app.views[0].selection.select(None); - let status = app.status.clone(); - app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); - assert_eq!(app.status, status); - app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::SavedVideos(None) - ); - assert!(app.job.is_none()); - assert!(app.key_hints.footer(Context::Normal).contains("F6/F7")); - assert!(app.key_hints.saved_videos.contains("F8")); - app.key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT)); - app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); - assert!(app.collection_removal.is_none()); - app.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)); - assert!(app.collection_removal.is_some()); - app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!( - app.video_state - .videos(CollectionKind::WatchLater) - .is_empty() - ); - assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); - assert!(!app.video_state.is_watched("jNQXAC9IVRw")); - } - - #[test] - fn saved_green_precedes_watch_later_magenta_without_changing_creator_or_selection_colors() { - let mut app = app(&["tubular"]); - app.config.categories.insert( - "Tagged".into(), - vec![config::normalize_channel("@creator").unwrap()], - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Saved title","channel":"Creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Later title","channel":"Other","uploader_id":"@other"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - for row in 0..2 { - app.views[0].selection.select(Some(row)); - app.save_collection(CollectionKind::WatchLater); - } - app.views[0].selection.select(Some(0)); - app.save_collection(CollectionKind::Saved); - app.views[0].selection.select(None); - let mut terminal = - ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - assert!( - buffer - .content - .iter() - .any(|cell| cell.symbol() == "S" && cell.fg == Color::Green) - ); - assert!( - buffer - .content - .iter() - .any(|cell| cell.symbol() == "L" && cell.fg == Color::Magenta) - ); - assert!( - buffer - .content - .iter() - .any(|cell| cell.symbol() == "C" && cell.fg == Color::Cyan) - ); - app.views[0].selection.select(Some(0)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert!( - terminal - .backend() - .buffer() - .content - .iter() - .any(|cell| cell.symbol() == "S" - && cell.fg == Color::Black - && cell.bg == Color::Cyan) - ); - app.browse_collection(CollectionKind::Saved); - app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); - app.switch_view(0); - app.views[0].selection.select(None); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert!( - terminal - .backend() - .buffer() - .content - .iter() - .any(|cell| cell.symbol() == "S" && cell.fg == Color::Magenta) - ); - } - - #[test] - fn paste_targets_only_active_inputs_and_refilters_without_executing_shortcuts() { - let mut app = app(&["tubular"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.paste("Haru 日本語\r\ns\t\x1b[31m"); - assert_eq!(app.views[0].query, "Haru 日本語 s [31m"); - assert_eq!(app.mode, Mode::Insert); - assert!(app.views[0].submitted_query.is_empty()); - assert!(app.job.is_none(), "paste must not submit a search"); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.paste("i\nq"); - assert_eq!(app.views[0].query, "Haru 日本語 s [31m"); - assert_eq!(app.mode, Mode::Normal); - - app.config.categories = [("Alpha".into(), vec![]), ("日本語".into(), vec![])].into(); - app.config.channels = [ - ( - "@Alpha".into(), - config::normalize_channel("@Alpha").unwrap(), - ), - ( - "@日本語".into(), - config::normalize_channel("@日本語").unwrap(), - ), - ] - .into(); - for kind in [BrowserKind::Categories, BrowserKind::Channels] { - app.open_browser(kind); - app.paste("Alpha"); - assert!(app.browser.as_ref().unwrap().query.is_empty()); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.paste("日本語"); - let browser = app.browser.as_ref().unwrap(); - assert_eq!(browser.matches, [1]); - assert_eq!(browser.selection.selected(), Some(0)); - assert!(browser.choices[browser.matches[0]].0.contains("日本語")); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - } - - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@a".into(), - channel: String::new(), - }, - "Creator".into(), - [("Alpha".into(), true), ("日本語".into(), false)].into(), - )); - app.paste("i\n "); - assert!(app.category_dialog.as_ref().unwrap().tags["Alpha"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.paste("日本語"); - assert_eq!(app.category_dialog.as_ref().unwrap().matches, [1]); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().tags["日本語"]); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - app.paste("New\nTag\t?"); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("New Tag ?") - ); - assert!( - app.help_popup.is_none(), - "pasted ? must remain literal text" - ); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().tags["New Tag ?"]); - assert!( - !app.config.categories.contains_key("New Tag ?"), - "paste must not save tags" - ); - app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); - app.paste("n\nq"); - assert!(app.category_dialog.as_ref().unwrap().name.is_none()); - assert!( - app.help_popup.is_some(), - "paste must not close help or operate on its dialog" - ); - } - - fn loaded_channel_views(app: &mut App) { - let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; - for (label, identity) in app.config.saved_channels() { - let mut view = View::new(label, ViewSource::Channel(identity)); - view.videos = feed_fixture(videos).unwrap().videos; - view.loaded = true; - view.next_start = 41; - view.selection.select(Some(1)); - app.views.push(view); - } - } - - fn browse_channel(app: &mut App, filter: &str) { - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - for c in filter.chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - } - - #[test] - fn video_channel_colors_follow_category_membership_and_preserve_selection_contrast() { - use ratatui::{Terminal, backend::TestBackend}; - - let mut app = app(&["tubular"]); - let tagged_id = "UC4QobU6STFB0P71PMvOGN5A"; - let registered_id = "UCaYhcUwRBNscFNUKTjgPFiA"; - app.config.channels.insert( - "@mapped".into(), - config::normalize_channel(tagged_id).unwrap(), - ); - app.config.channels.insert( - "@registered".into(), - config::normalize_channel(registered_id).unwrap(), - ); - app.config.categories.insert( - "Favorites".into(), - vec![ - config::normalize_channel(tagged_id).unwrap(), - config::normalize_channel("@direct").unwrap(), - ], - ); - let cases = [ - ("TaggedID", Some(tagged_id), None, true), - ("TaggedHandle", None, Some("@direct"), true), - ("MappedHandle", None, Some("@mapped"), true), - ( - "Registered", - Some(registered_id), - Some("@registered"), - false, - ), - ("TaggedID", Some(registered_id), None, false), - ("Unknown", None, None, false), - ]; - app.views[0].videos = youtube::search_results( - &serde_json::json!({ - "entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({ - "id": "jNQXAC9IVRw", "title": "Video title", "channel": channel, - "channel_id": id, "uploader_id": handle, "upload_date": "20261011" - })).collect::>() - }) - .to_string(), - ) - .unwrap() - .videos; - app.views[0].selection.select(None); - let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap(); - for cleared in [false, true] { - if cleared { - app.config.categories.clear(); - } - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - // One tab row, three search rows, then the table border and header. - for (index, (label, _, _, tagged)) in cases.iter().enumerate() { - let y = 6 + index as u16; - let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect(); - let x = row.find(label).expect("channel label is visible") as u16; - assert_eq!( - buffer[(x, y)].fg, - if *tagged && !cleared { - Color::Cyan - } else { - Color::Reset - }, - "{label} in row {index}, cleared={cleared}" - ); - assert_eq!(buffer[(3, y)].fg, Color::Reset, "title stays uncolored"); - assert_eq!(buffer[(108, y)].fg, Color::Reset, "date stays uncolored"); - } - if !cleared { - app.views[0].selection.select(Some(0)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let row: String = (0..120).map(|x| buffer[(x, 6)].symbol()).collect(); - let x = row.find("TaggedID").unwrap() as u16; - assert_eq!(buffer[(x, 6)].fg, Color::Black); - assert_eq!(buffer[(x, 6)].bg, Color::Cyan); - app.views[0].selection.select(None); - } - } - } - - #[test] - fn remapped_preview_and_input_actions_survive_key_only_config_reload() { - use ratatui::{Terminal, backend::TestBackend}; - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, - ).unwrap().videos; - // Invalid IDs avoid curl; full descriptions exercise the real preview without extraction. - for (index, id) in ["first", "second"].into_iter().enumerate() { - app.views[0].videos[index].id = id.into(); - app.remember_description(id.into(), "A long description paragraph.\n\n".repeat(40)); - } - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.views[0].next_start = 41; - let document = r#"[keys] -video.info.overview = "I" -video.info.hide = "x" -view.collection.remove.selected = [] -search.edit = "f2" -input.delete.word = "ctrl+backspace" -input.delete.line = "ctrl+l" -"# - .parse::() - .unwrap(); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); - press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE); - assert_eq!(app.preview, Some(PreviewMode::Description)); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); - assert_eq!(app.description_scroll, 1); - press(&mut app, KeyCode::Char('I'), KeyModifiers::SHIFT); - assert_eq!(app.preview, Some(PreviewMode::Info)); - press(&mut app, KeyCode::Char('x'), KeyModifiers::NONE); - assert_eq!(app.preview, None); - assert!(app.description_job.is_none()); - let remap = "[keys]\nvideo.select.next = 'n'\n" - .parse::() - .unwrap(); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(remap.get("keys")).unwrap(), - ..Default::default() - }); - assert_eq!(app.views[0].next_start, 41); - assert_eq!(app.views[0].videos[0].title, "First"); - press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); - assert_eq!(app.views[0].selection.selected(), Some(0)); - press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.apply_config(config::Config { - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); - assert_eq!(app.mode, Mode::Normal); - press(&mut app, KeyCode::F(2), KeyModifiers::NONE); - for character in "red blue I d?".chars() { - press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); - } - assert_eq!(app.views[0].query, "red blue I d?"); - assert_eq!(app.preview, None); - press(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL); - assert_eq!(app.views[0].query, "red blue I "); - press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); - assert_eq!(app.views[0].query, "red blue I "); - press(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL); - assert_eq!(app.views[0].query, ""); - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - assert_eq!(app.mode, Mode::Normal); - } - - #[test] - fn remapped_selector_and_tag_keys_keep_input_and_modal_navigation_separate() { - let mut app = app(&["tubular"]); - let document = r#"[keys] -selector.select.next = "l" -selector.edit = "f3" -selector.close = "z" -input.submit = "ctrl+e" -input.cancel = "ctrl+b" -tags.new = "f4" -tags.cancel = "z" -"# - .parse::() - .unwrap(); - app.apply_config(config::Config { - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), - ..Default::default() - }); - loaded_channel_views(&mut app); - let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); - app.open_browser(BrowserKind::Channels); - press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); - press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - press(&mut app, KeyCode::F(3), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); - assert_eq!(app.browser.as_ref().unwrap().query, "l"); - assert_eq!(app.browser.as_ref().unwrap().matches, [0]); - press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); - press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - press(&mut app, KeyCode::Enter, KeyModifiers::NONE); - assert!(app.browser.is_some()); - press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); - assert!(app.browser.is_none()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - assert_eq!(app.views[app.view_index].next_start, 41); - let history = app.view_history.clone(); - app.category_dialog = Some(CategoryDialog { - name: None, - ..CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Beta".into(), - channel: "beta".into(), - }, - "@Beta".into(), - BTreeMap::new(), - ) - }); - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - assert_eq!(app.view_history, history); - assert!(app.category_dialog.is_some()); - press(&mut app, KeyCode::F(4), KeyModifiers::NONE); - for character in "lI?".chars() { - press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); - } - press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); - assert_eq!( - app.category_dialog.as_ref().unwrap().tags.get("lI?"), - Some(&true) - ); - press(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); - assert!(app.category_dialog.is_none()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - } - - #[test] - fn help_scrolls_without_navigating_and_closes_to_the_same_preview() { - use ratatui::{Terminal, backend::TestBackend}; - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - browse_channel(&mut app, "Alpha"); - browse_channel(&mut app, "Beta"); - let history = app.view_history.clone(); - let selected = app.views[app.view_index].selection.selected(); - let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE)); - app.views[app.view_index].has_more = true; - app.views[app.view_index].prefetch_requested = true; - assert!(app.can_prefetch()); - press(&mut app, KeyCode::Char('?')); - assert!(!app.can_prefetch()); - app.preview = Some(PreviewMode::Info); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - press(&mut app, KeyCode::Char('j')); - assert_eq!(app.help_popup.as_ref().unwrap().scroll, 1); - for code in [ - KeyCode::Enter, - KeyCode::Tab, - KeyCode::Char('-'), - KeyCode::Char('+'), - ] { - press(&mut app, code); - } - assert_eq!(app.view_history, history); - assert!(app.forward_history.is_empty()); - assert!(app.player_launch.is_none()); - assert_eq!(app.views[app.view_index].selection.selected(), selected); - for _ in 0..100 { - press(&mut app, KeyCode::PageDown); - } - let help = app.help_popup.as_ref().unwrap(); - assert_eq!(help.scroll, help.max_scroll); - let previous_max = help.max_scroll; - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert_eq!(app.help_popup.as_ref().unwrap().scroll, previous_max - 1); - let mut larger = Terminal::new(TestBackend::new(120, 40)).unwrap(); - larger - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let help = app.help_popup.as_ref().unwrap(); - assert_eq!(help.scroll, help.max_scroll); - for _ in 0..100 { - press(&mut app, KeyCode::PageUp); - } - assert_eq!(app.help_popup.as_ref().unwrap().scroll, 0); - press(&mut app, KeyCode::Esc); - assert!(app.help_popup.is_none()); - assert_eq!(app.preview, Some(PreviewMode::Info)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - press(&mut app, KeyCode::Char('-')); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("alpha".into()) - ); - press(&mut app, KeyCode::Char('+')); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - } - - #[test] - fn help_preserves_selector_state_and_question_mark_is_literal_in_every_input() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [("@Alpha".into(), "alpha".into())].into(), - ..Default::default() - }); - let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE)); - app.open_browser(BrowserKind::Channels); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_some()); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_none()); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); - press(&mut app, KeyCode::Char('i')); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_none()); - assert_eq!(app.browser.as_ref().unwrap().query, "?"); - assert!(app.browser.as_ref().unwrap().matches.is_empty()); - press(&mut app, KeyCode::Esc); - press(&mut app, KeyCode::Esc); - press(&mut app, KeyCode::Char('i')); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_none()); - assert_eq!(app.views[0].query, "?"); - press(&mut app, KeyCode::Esc); - app.category_dialog = Some(CategoryDialog { - name: Some(String::new()), - ..CategoryDialog::new( - CategoryTarget::Channel { - handle: "@Alpha".into(), - channel: "alpha".into(), - }, - "@Alpha".into(), - BTreeMap::new(), - ) - }); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_none()); - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("?") - ); - press(&mut app, KeyCode::Esc); - press(&mut app, KeyCode::Char('?')); - assert!(app.help_popup.is_some()); - press(&mut app, KeyCode::Esc); - assert!(app.category_dialog.is_some()); - assert!(app.help_popup.is_none()); - } - - #[test] - fn plus_restores_forward_views_and_new_navigation_discards_the_forward_branch() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - for view in &mut app.views { - view.loaded = true; - } - browse_channel(&mut app, "Alpha"); - browse_channel(&mut app, "Beta"); - let press = |app: &mut App, c| { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - }; - press(&mut app, '-'); - press(&mut app, '-'); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - press(&mut app, '+'); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("alpha".into()) - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.views[app.view_index].next_start, 41); - app.switch_view(app.view_index); // Refocusing does not discard forward history. - press(&mut app, '+'); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - press(&mut app, '+'); // End of history is a no-op. - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - press(&mut app, '-'); - app.switch_view(1); - press(&mut app, '+'); - assert_eq!(app.views[app.view_index].source, ViewSource::Feed); - app.mode = Mode::Insert; - press(&mut app, '+'); - assert_eq!(app.views[0].query, "+"); - assert_eq!(app.views[app.view_index].source, ViewSource::Feed); - assert!(app.job.is_none()); - } - - #[test] - fn forward_navigation_skips_unsubscribed_views() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - app.views[0].loaded = true; - browse_channel(&mut app, "Alpha"); - browse_channel(&mut app, "Beta"); - app.back_view(); - app.back_view(); - app.apply_config(config::Config { - channels: [("@Beta".into(), "beta".into())].into(), - ..Default::default() - }); - app.key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - assert_eq!(app.temporary_view, Some(ViewSource::Channel("beta".into()))); - assert!(app.job.is_none()); - } - - #[test] - fn escape_and_minus_restore_view_history_without_consuming_modal_escape() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [("Category".into(), Vec::new())].into(), - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - for view in &mut app.views { - view.loaded = true; - } - app.switch_view(2); - browse_channel(&mut app, "Alpha"); - browse_channel(&mut app, "Beta"); - app.switch_view(app.view_index); // Refocusing must not add a history entry. - app.preview = Some(PreviewMode::Info); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.preview.is_none()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - app.mode = Mode::Insert; - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.views[0].query, "-"); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - app.open_browser(BrowserKind::Channels); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.browser.is_none()); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("beta".into()) - ); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("alpha".into()) - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.views[app.view_index].next_start, 41); - assert_eq!( - app.temporary_view, - Some(ViewSource::Channel("alpha".into())) - ); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Category".into()) - ); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - assert!(app.job.is_none()); - } - - #[test] - fn back_history_uses_source_identity_after_config_reorders_or_removes_views() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [("Earlier".into(), Vec::new()), ("Keep".into(), Vec::new())].into(), - channels: [("@Alpha".into(), "alpha".into())].into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - for view in &mut app.views { - view.loaded = true; - } - app.switch_view(3); // Keep initially follows Earlier. - browse_channel(&mut app, "Alpha"); - app.apply_config(config::Config { - categories: [("Keep".into(), Vec::new())].into(), - channels: [("@Alpha".into(), "alpha".into())].into(), - ..Default::default() - }); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Keep".into()) - ); - browse_channel(&mut app, "Alpha"); - app.apply_config(config::Config { - channels: [("@Alpha".into(), "alpha".into())].into(), - ..Default::default() - }); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - assert!(app.job.is_none()); - } - - #[test] - fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() { - let mut app = app(&["tubular"]); - let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; - app.apply_config(config::Config { - channels: [("@Saved".into(), identity.into())].into(), - ..Default::default() - }); - loaded_channel_views(&mut app); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Saved"}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.preview = Some(PreviewMode::Description); - let press = |app: &mut App| { - app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT)); - }; - press(&mut app); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel(identity.into()) - ); - assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); - assert_eq!(app.views[app.view_index].next_start, 41); - assert_eq!(app.views[app.view_index].videos[1].title, "Second"); - assert!(app.preview.is_none()); - assert!(app.job.is_none()); - let count = app.views.len(); - press(&mut app); - assert_eq!(app.views.len(), count); - app.switch_view(0); - app.views[0].videos[0].channel_id = None; - press(&mut app); // A handle-only row resolves through the saved mapping. - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel(identity.into()) - ); - app.switch_view(0); - app.views[0].videos[0].uploader_id = Some("@invalid/handle".into()); - press(&mut app); - assert_eq!(app.view_index, 0); - assert_eq!(app.views.len(), count); - assert!(app.job.is_none()); - app.views[0].videos[0].uploader_id = None; - press(&mut app); - assert_eq!(app.view_index, 0); - app.mode = Mode::Insert; - press(&mut app); - assert_eq!(app.views[0].query, "U"); - assert_eq!(app.view_index, 0); - } - - #[test] - fn unsaved_video_channels_survive_config_changes_without_subscribing() { - let mut app = app(&["tubular"]); - let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; - let mut channel = View::new("@Unsaved".into(), ViewSource::Channel(identity.into())); - channel.loaded = true; - app.views.push(channel); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Unsaved"}]}"#, - ).unwrap().videos; - app.views[0].selection.select(Some(0)); - app.views[0].loaded = true; - app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel(identity.into()) - ); - assert!(app.config.subscriptions().is_empty()); - app.apply_config(config::Config { - categories: [("Unrelated".into(), Vec::new())].into(), - ..Default::default() - }); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel(identity.into()) - ); - assert!(app.views[app.view_index].loaded); - assert_eq!( - app.temporary_view, - Some(ViewSource::Channel(identity.into())) - ); - app.apply_config(config::Config { - channels: [("@Registered".into(), identity.into())].into(), - ..Default::default() - }); - assert_eq!(app.views[app.view_index].name, "@Registered"); - assert!(app.views[app.view_index].loaded); - app.apply_config(config::Config::default()); - assert_eq!(app.view_index, 0); - assert!( - app.views - .iter() - .all(|view| view.source != ViewSource::Channel(identity.into())) - ); - } - - #[test] - fn channels_filter_in_both_modes_without_pinning_or_running_modal_shortcuts() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [ - ("@jazz".into(), "jazz identity".into()), - ("@jokes".into(), "jokes identity".into()), - ] - .into(), - ..config::Config::default() - }); - loaded_channel_views(&mut app); - app.views[0].query = "untouched".into(); - app.views[0].loaded = true; - app.views[0].has_more = true; - app.preview = Some(PreviewMode::Info); - assert!(!app.can_prefetch()); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); - assert!(app.preview.is_none()); - assert!(!app.can_prefetch()); - app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - assert!(app.config.pinned_categories.is_empty()); - assert!(app.browser.as_ref().unwrap().error.is_none()); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - for c in "jkp".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - assert_eq!(app.browser.as_ref().unwrap().query, "jkp"); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 0); - assert!(app.browser.is_some()); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - app.key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE)); - for (code, modifiers, row) in [ - (KeyCode::Char('j'), KeyModifiers::CONTROL, 1), - (KeyCode::Char('k'), KeyModifiers::CONTROL, 0), - (KeyCode::Down, KeyModifiers::NONE, 1), - (KeyCode::Up, KeyModifiers::NONE, 0), - ] { - app.key(KeyEvent::new(code, modifiers)); - assert_eq!( - app.browser.as_ref().unwrap().selection.selected(), - Some(row) - ); - } - assert_eq!(app.browser.as_ref().unwrap().query, "J"); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel("jazz identity".into()) - ); - assert_eq!( - app.views[app.view_index].source.channels( - &app.config, - &app.feed_channels, - &app.views[app.view_index].group_channels - ), - ["jazz identity"] - ); - assert_eq!(app.views[0].query, "untouched"); - assert!(app.browser.is_none()); - assert!(app.job.is_none()); - } - - #[test] - fn channel_and_category_share_temporary_tab_and_cached_history() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - keys: Default::default(), - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - categories: [ - ("@Alpha".into(), vec!["beta".into()]), - ("Temporary".into(), vec![]), - ] - .into(), - pinned_categories: vec!["@Alpha".into()], - }); - loaded_channel_views(&mut app); - app.views[2].loaded = true; - app.switch_view(3); - browse_channel(&mut app, "Alpha"); - let alpha_source = ViewSource::Channel("alpha".into()); - assert_eq!(app.views[app.view_index].source, alpha_source); - assert_eq!( - app.views[app.view_index].source.channels( - &app.config, - &app.feed_channels, - &app.views[app.view_index].group_channels - ), - ["alpha"] - ); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - assert!(app.category_dialog.is_none()); - assert_eq!(app.views[app.view_index].source, alpha_source); - browse_channel(&mut app, "Beta"); - let visible: Vec<_> = app - .visible_views() - .map(|(_, view)| view.source.clone()) - .collect(); - assert_eq!( - visible, - [ - ViewSource::Search, - ViewSource::Feed, - ViewSource::Category("@Alpha".into()), - ViewSource::Channel("beta".into()) - ] - ); - assert!(app.views.iter().any(|view| view.source == alpha_source)); - browse_channel(&mut app, "Alpha"); - let view = &app.views[app.view_index]; - assert_eq!(view.source, alpha_source); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.next_start, 41); - assert_eq!(view.videos.len(), 2); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Category("Temporary".into()) - ); - assert!( - !app.visible_views() - .any(|(_, view)| matches!(view.source, ViewSource::Channel(_))) - ); - browse_channel(&mut app, "Alpha"); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].source, alpha_source); - assert!(app.job.is_none()); - } - - #[test] - fn channel_refresh_matches_identities_relabels_caches_and_removes_unsubscribed_views() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [ - ("@Alpha".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - categories: [("@Alpha".into(), vec!["beta".into()])].into(), - ..config::Config::default() - }); - loaded_channel_views(&mut app); - browse_channel(&mut app, "Alpha"); - app.views[app.view_index].has_more = true; - app.views[app.view_index].feed_requests = - app.views[app.view_index].feed_cursor.plan(1).unwrap(); - app.views[app.view_index].prefetch_requested = true; - app.views[app.view_index].page_error = Some("kept".into()); - app.apply_config(config::Config { - keys: Default::default(), - channels: [ - ("@Renamed".into(), "alpha".into()), - ("@Beta".into(), "beta".into()), - ] - .into(), - categories: [ - ("New tag".into(), vec!["alpha".into()]), - ("@Alpha".into(), vec![]), - ] - .into(), - pinned_categories: vec!["@Alpha".into()], - }); - let view = &app.views[app.view_index]; - assert_eq!(view.source, ViewSource::Channel("alpha".into())); - assert_eq!(view.name, "@Renamed"); - assert_eq!(view.title, " @Renamed "); - assert!(view.loaded && view.has_more); - assert_eq!(view.next_start, 41); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.feed_requests.len(), 1); - assert!(view.prefetch_requested); - assert_eq!(view.page_error.as_deref(), Some("kept")); - let beta = app - .views - .iter() - .find(|view| view.source == ViewSource::Channel("beta".into())) - .unwrap(); - assert_eq!(beta.next_start, 41); - assert_eq!(beta.selection.selected(), Some(1)); - app.apply_config(config::Config { - keys: Default::default(), - channels: [("@Beta".into(), "beta".into())].into(), - categories: [("@Alpha".into(), vec!["beta".into()])].into(), - pinned_categories: vec!["@Alpha".into()], - }); - assert_eq!(app.views[app.view_index].source, ViewSource::Search); - assert!(app.temporary_view.is_none()); - assert!( - !app.views - .iter() - .any(|view| view.source == ViewSource::Channel("alpha".into())) - ); - browse_channel(&mut app, "Beta"); - assert_eq!(app.views[app.view_index].next_start, 41); - app.apply_config(config::Config::default()); - assert_eq!(app.view_index, 0); - assert_eq!(app.views.len(), 2); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); - for code in [KeyCode::Down, KeyCode::Enter, KeyCode::Char('p')] { - app.key(KeyEvent::new(code, KeyModifiers::NONE)); - } - assert!(app.browser.as_ref().unwrap().choices.is_empty()); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.browser.is_none()); - assert!(app.job.is_none()); - } - - #[test] - fn channel_labels_cannot_alias_builtin_or_category_sources() { - let mut app = app(&["tubular"]); - // Raw fixture identities exercise equal labels without invoking an extractor. - app.apply_config(config::Config { - categories: [ - ("Feed".into(), vec!["Feed".into(), "Search".into()]), - ("Search".into(), vec![]), - ] - .into(), - pinned_categories: vec!["Feed".into(), "Search".into()], - ..config::Config::default() - }); - loaded_channel_views(&mut app); - for label in ["Feed", "Search"] { - browse_channel(&mut app, label); - assert_eq!( - app.views[app.view_index].source, - ViewSource::Channel(label.into()) - ); - assert_eq!( - app.views[app.view_index].source.channels( - &app.config, - &app.feed_channels, - &app.views[app.view_index].group_channels - ), - [label] - ); - assert_eq!( - app.visible_views() - .filter(|(_, view)| view.name == label) - .count(), - 3 - ); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - assert!(app.category_dialog.is_none()); - } - assert!(app.job.is_none()); - } - - #[test] - fn channel_selector_renders_plain_labels_modes_empty_states_and_filter_cursor() { - use ratatui::{Terminal, backend::TestBackend}; - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - channels: [("@Alpha".into(), "alpha".into())].into(), - ..config::Config::default() - }); - let mut terminal = Terminal::new(TestBackend::new(180, 40)).unwrap(); - let text = |terminal: &Terminal| -> String { - terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect() - }; - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let rendered = text(&terminal); - assert!(rendered.contains("@Alpha")); - assert!(!rendered.contains("[p]") && !rendered.contains("[ ]")); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let popup = preview_area(Rect::new(0, 0, 180, 40)); - assert_eq!( - terminal.get_cursor_position().unwrap(), - (popup.x + 3, popup.y + 2).into() - ); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.apply_config(config::Config::default()); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert!(app.job.is_none()); - } - #[test] - fn filtered_tagging_saves_visible_changes_without_losing_hidden_memberships() { - use ratatui::{Terminal, backend::TestBackend}; - - for bulk in [false, true] { - let path = std::env::temp_dir().join(format!( - "tubular-filtered-tags-{}-{bulk}.toml", - std::process::id() - )); - let original = "[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@a', '@b']\nGamma = []\nZebra = ['@a', '@b']\n"; - std::fs::write(&path, original).unwrap(); - let mut app = App::new( - Options::parse_from(["tubular"]), - path.clone(), - config::load(&path).unwrap(), - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Creator video","channel":"Creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@a"}]}"# - ).unwrap().videos; - app.views[0].selection.select(Some(0)); - if bulk { - app.view_index = app - .views - .iter() - .position(|view| view.name == "Alpha") - .unwrap(); - } - app.views[app.view_index].loaded = true; - let press = |app: &mut App, code, modifiers| { - app.key(KeyEvent::new(code, modifiers)); - }; - press( - &mut app, - KeyCode::Char(if bulk { 'S' } else { 's' }), - KeyModifiers::NONE, - ); - press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); - for c in "missing".chars() { - press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); - } - let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let text: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - assert!(text.contains("No matching tags.")); - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); - for c in "GA".chars() { - press(&mut app, KeyCode::Char(c), KeyModifiers::SHIFT); - } - press(&mut app, KeyCode::Enter, KeyModifiers::NONE); - assert!(app.category_dialog.is_some(), "filter Enter must not save"); - assert_eq!(std::fs::read_to_string(&path).unwrap(), original); - press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let text: String = terminal - .backend() - .buffer() - .content - .iter() - .map(|cell| cell.symbol()) - .collect(); - assert!(text.contains("[x] Gamma")); - assert!(!text.contains("[x] Alpha") && !text.contains("[x] Zebra")); - press(&mut app, KeyCode::Enter, KeyModifiers::NONE); - assert!(app.category_dialog.is_none()); - let saved = config::load(&path).unwrap(); - let a = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); - let b = config::normalize_channel("UCaYhcUwRBNscFNUKTjgPFiA").unwrap(); - assert_eq!(saved.categories["Alpha"], [a.clone(), b.clone()]); - assert_eq!(saved.categories["Zebra"], [a.clone(), b.clone()]); - assert_eq!( - saved.categories["Gamma"], - if bulk { vec![a, b] } else { vec![a] } - ); - std::fs::remove_file(path).unwrap(); - } - } - - #[test] - fn tag_filter_preserves_selection_and_stages_new_tags_outside_the_filter() { - let mut app = app(&["tubular"]); - app.category_dialog = Some(CategoryDialog::new( - CategoryTarget::Channel { - handle: "@a".into(), - channel: String::new(), - }, - "Creator".into(), - [ - ("Alpha".into(), false), - ("Gamma".into(), true), - ("Zebra".into(), false), - ] - .into(), - )); - let press = |app: &mut App, code, modifiers| { - app.key(KeyEvent::new(code, modifiers)); - }; - press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('a'), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); - assert!(app.category_dialog.as_ref().unwrap().tags["Zebra"]); - assert!(!app.category_dialog.as_ref().unwrap().tags["Alpha"]); - press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); - press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); - for c in "gamma".chars() { - press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); - } - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE); - for c in "New".chars() { - press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); - } - press(&mut app, KeyCode::Enter, KeyModifiers::NONE); - let dialog = app.category_dialog.as_ref().unwrap(); - assert!(dialog.tags["New"]); - assert!(dialog.query.is_empty(), "new tag must be visible"); - press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); - assert!( - !app.category_dialog.as_ref().unwrap().tags["New"], - "new tag is selected" - ); - press(&mut app, KeyCode::Esc, KeyModifiers::NONE); - assert!(app.category_dialog.is_none()); - assert!( - app.config.categories.is_empty(), - "cancel discards staged changes" - ); - } - - #[test] - fn bulk_picker_uses_all_saved_channels_and_commits_or_cancels_the_whole_group() { - let path = - std::env::temp_dir().join(format!("tubular-bulk-picker-{}.toml", std::process::id())); - let original = "[settings]\npinned_categories = ['Source']\n[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@other' = 'UCBR8-60-B28hp2BmDPdntcQ'\n[categories]\nEmpty = []\nMixed = ['@a', '@other']\nShared = ['@a', '@b', '@other']\nSource = ['@a', '@b']\n"; - std::fs::write(&path, original).unwrap(); - let mut app = App::new( - Options::parse_from(["tubular"]), - path.clone(), - config::load(&path).unwrap(), - ); - loaded_channel_views(&mut app); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - assert!(app.category_dialog.is_none()); - app.view_index = app - .views - .iter() - .position(|view| view.name == "Source") - .unwrap(); - app.views[app.view_index].loaded = true; - app.views[1].loaded = true; - app.views[1].next_start = 41; - let subscriptions = app.feed_channels.clone(); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - let dialog = app.category_dialog.as_ref().unwrap(); - assert!(dialog.tags["Shared"] && dialog.tags["Source"]); - assert!(!dialog.tags["Mixed"] && !dialog.tags["Empty"]); - assert!( - matches!(&dialog.target, CategoryTarget::Category { channels, .. } if channels.len() == 2) - ); - assert!(app.views[app.view_index].videos.is_empty()); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - for c in "Staged".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(std::fs::read_to_string(&path).unwrap(), original); - assert!(!app.config.categories.contains_key("Staged")); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - let saved = config::load(&path).unwrap(); - assert!(saved.categories["Source"].is_empty()); - assert_eq!( - saved.categories["Mixed"], - ["https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"] - ); - assert_eq!(saved.categories["Shared"].len(), 3); - assert_eq!(saved.pinned_categories, ["Source"]); - assert_eq!(app.feed_channels, subscriptions); - assert!(app.views[1].loaded); - assert_eq!(app.views[1].next_start, 41); - browse_channel(&mut app, "@a"); - let channel_source = app.views[app.view_index].source.clone(); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - let channel = &app.views[app.view_index]; - assert_eq!(channel.source, channel_source); - assert!(channel.loaded); - assert_eq!(channel.videos.len(), 2); - assert_eq!(channel.selection.selected(), Some(1)); - assert_eq!(channel.next_start, 41); - assert!(app.job.is_none()); - app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); - assert!(app.category_dialog.is_none()); - std::fs::remove_file(path).unwrap(); - } - - #[test] - fn filtering_preserves_selected_categories_and_browses_the_filtered_row() { - let mut app = app(&["tubular"]); - app.views[0].query = "main search untouched".into(); - app.apply_config(config::Config { - categories: [ - ("Alpha".into(), vec![]), - ("Japanese immersion".into(), vec![]), - ("Japanese news".into(), vec![]), - ] - .into(), - ..config::Config::default() - }); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - for c in "JAPANESE".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - for c in " news".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].name, "Japanese news"); - assert_eq!(app.views[0].query, "main search untouched"); - assert!(app.browser.is_none()); - } - - #[test] - fn insert_filter_types_jk_handles_empty_matches_and_navigates_without_editing() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [ - ("Alpha".into(), vec![]), - ("Jazz".into(), vec![]), - ("Jokes".into(), vec![]), - ("日本語だけで".into(), vec![]), - ] - .into(), - ..config::Config::default() - }); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - for c in "jk".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - assert!(app.browser.as_ref().unwrap().selection.selected().is_none()); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.browser.is_some()); - assert_eq!(app.view_index, 0); - app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - for (code, modifiers, row) in [ - (KeyCode::Char('j'), KeyModifiers::CONTROL, 1), - (KeyCode::Char('k'), KeyModifiers::CONTROL, 0), - (KeyCode::Down, KeyModifiers::NONE, 1), - (KeyCode::Up, KeyModifiers::NONE, 0), - ] { - app.key(KeyEvent::new(code, modifiers)); - assert_eq!( - app.browser.as_ref().unwrap().selection.selected(), - Some(row) - ); - } - assert_eq!(app.browser.as_ref().unwrap().query, "j"); - app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); - for c in "日本".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].name, "日本語だけで"); - assert_eq!(app.mode, Mode::Normal); - } - - #[test] - fn category_browser_replaces_the_temporary_tab_and_cycles_only_visible_views() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [ - ("Alpha".into(), vec![]), - ("Beta".into(), vec![]), - ("Gamma".into(), vec![]), - ] - .into(), - ..config::Config::default() - }); - let visible = |app: &App| { - app.visible_views() - .map(|(_, view)| view.name.clone()) - .collect::>() - }; - assert_eq!(visible(&app), ["Search", "Feed"]); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 0); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.views[app.view_index].name, "Beta"); - assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]); - app.apply_config(config::Config { - categories: app.config.categories.clone(), - pinned_categories: vec!["Beta".into()], - ..config::Config::default() - }); - assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 0); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].name, "Gamma"); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].name, "Beta"); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); - assert!(app.browser.is_none()); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); - } - - #[test] - fn categories_named_search_or_feed_are_distinct_from_permanent_views() { - let mut app = app(&["tubular"]); - app.apply_config(config::Config { - categories: [ - ("Alpha".into(), vec![]), - ("Feed".into(), vec![]), - ("Search".into(), vec![]), - ] - .into(), - ..config::Config::default() - }); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.view_index, 3); - assert_eq!(app.views[app.view_index].name, "Feed"); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); - assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); - } - - #[test] - fn descriptions_are_shared_across_views_and_evict_least_recently_used_entries() { - let mut app = app(&["tubular"]); - let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared video"}]}"#; - for view in &mut app.views { - view.videos = youtube::search_results(output).unwrap().videos; - view.selection.select(Some(0)); - view.loaded = true; - } - app.remember_description("jNQXAC9IVRw".into(), "Shared full description".into()); - for index in 1..DESCRIPTION_CACHE_SIZE { - app.remember_description(format!("video{index:06}"), format!("Description {index}")); - } - app.switch_view(1); - app.load_description(); // A hit in Feed promotes the Search video's description. - assert!(app.description_job.is_none()); - app.remember_description("new00000000".into(), "New description".into()); - assert_eq!( - cached_description(&app.descriptions, "jNQXAC9IVRw"), - Some("Shared full description") - ); - assert_eq!(cached_description(&app.descriptions, "video000001"), None); - assert_eq!( - cached_description(&app.descriptions, "video000002"), - Some("Description 2") - ); - // Updating an existing entry, including a known empty description, must not evict another. - app.remember_description("jNQXAC9IVRw".into(), String::new()); - app.load_description(); - assert!(app.description_job.is_none()); - assert_eq!( - cached_description(&app.descriptions, "jNQXAC9IVRw"), - Some("") - ); - assert_eq!( - cached_description(&app.descriptions, "video000002"), - Some("Description 2") - ); - } - - #[test] - fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() { - use ratatui::{Terminal, backend::TestBackend, style::Modifier}; - - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#, - ).unwrap().videos; - app.views[0].selection.select(Some(0)); - app.preview = Some(PreviewMode::Info); - for (last_line, hidden) in [ - ("line", false), - ("line", true), - ("abcdefghijklmnopqrs", true), - ("a界界界界界界界界界", true), - ("", true), - ] { - app.remember_description( - "jNQXAC9IVRw".into(), - format!( - "{}{last_line}{}", - "line\n".repeat(6), - if hidden { "\nhidden content" } else { "" }, - ), - ); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let popup = preview_area(buffer.area); - let text_x = (popup.x + 1..popup.right() - 1) - .find(|&x| { - let cell = &buffer[(x, popup.y + 1)]; - cell.symbol() == "H" && cell.modifier.contains(Modifier::BOLD) - }) - .expect("title is visible"); - let row: String = (text_x..popup.right() - 3) - .map(|x| buffer[(x, popup.bottom() - 2)].symbol()) - .collect(); - if hidden { - assert!(row.trim_end().ends_with('…'), "{row}"); - if last_line.starts_with("a界") { - assert!(row.starts_with("a界")); - for x in text_x..popup.right() - 3 { - if buffer[(x, popup.bottom() - 2)].symbol() == "界" { - assert_eq!(buffer[(x + 1, popup.bottom() - 2)].symbol(), " "); - } - } - } - } else { - assert_eq!(row.trim_end(), "line"); - } - } - } - - #[test] - fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() { - use ratatui::{Terminal, backend::TestBackend, style::Modifier}; - - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#, - ).unwrap().videos; - app.remember_description( - "jNQXAC9IVRw".into(), - format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)), - ); - app.views[0].selection.select(Some(0)); - app.preview = Some(PreviewMode::Info); - for (width, height) in [(80, 24), (120, 40), (20, 10)] { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let popup = preview_area(Rect::new(0, 0, width, height)); - let mut excerpt_rows = Vec::new(); - for y in popup.y + 1..popup.bottom() - 1 { - for x in popup.right() - 3..popup.right() - 1 { - assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " "); - } - let row: String = (popup.x + popup.width / 2..popup.right() - 3) - .map(|x| buffer.cell((x, y)).unwrap().symbol()) - .collect(); - assert!(!row.contains("CLIPPED SENTINEL")); - if row.contains("Excerpt line") { - excerpt_rows.push(y); - } - } - if width == 20 { - assert!(excerpt_rows.is_empty()); // Heading takes all available height. - continue; - } - let text_x = (popup.x + 1..popup.right() - 1) - .find(|&x| { - let cell = &buffer[(x, popup.y + 1)]; - cell.symbol() == "F" && cell.modifier.contains(Modifier::BOLD) - }) - .expect("title is visible"); - let title = &buffer[(text_x, popup.y + 1)]; - assert!(title.modifier.contains(Modifier::BOLD)); - assert!(!title.modifier.contains(Modifier::DIM)); - let excerpt_y = *excerpt_rows - .last() - .expect("description fills remaining space"); - assert_eq!(excerpt_y, popup.bottom() - 2); - let excerpt = &buffer[(text_x, excerpt_y)]; - assert!(!excerpt.modifier.contains(Modifier::DIM)); - assert!(!excerpt.modifier.contains(Modifier::BOLD)); - } - } - - #[test] - fn view_tabs_wrap_whole_labels_and_keep_the_selected_row_visible() { - use ratatui::{Terminal, backend::TestBackend}; - - let mut app = app(&["tubular"]); - app.views = ["Search", "Feed", "Language immersion", "日本語だけ", "中文"] - .into_iter() - .enumerate() - .map(|(index, name)| { - View::new( - name.into(), - match index { - 0 => ViewSource::Search, - 1 => ViewSource::Feed, - _ => ViewSource::Category(name.into()), - }, - ) - }) - .collect(); - app.config.pinned_categories = app - .views - .iter() - .skip(2) - .map(|view| view.name.clone()) - .collect(); - app.view_index = 4; - app.mode = Mode::Insert; - for (width, height) in [(30, 20), (56, 20), (55, 20), (9, 20), (30, 12)] { - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let tab_height = terminal.get_cursor_position().unwrap().y - 1; - let buffer = terminal.backend().buffer(); - let locate = |symbol: &str| { - (0..tab_height) - .flat_map(|y| (0..width).map(move |x| (x, y))) - .find(|&position| buffer.cell(position).unwrap().symbol() == symbol) - }; - let chinese = locate("中").expect("selected category remains visible"); - assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan); - assert_eq!( - buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(), - "文" - ); - if width >= 16 { - let japanese = locate("日").expect("neighboring category remains visible"); - for (offset, character) in "日本語だけ".chars().enumerate() { - assert_eq!( - buffer - .cell((japanese.0 + offset as u16 * 2, japanese.1)) - .unwrap() - .symbol(), - character.to_string(), - "category label must not split across rows" - ); - } - assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan); - } - if width >= 24 { - assert!( - (0..tab_height).any(|y| { - (0..width) - .map(|x| buffer.cell((x, y)).unwrap().symbol()) - .collect::() - .contains("Language immersion") - }), - "whole category label must fit after wrapping" - ); - } - } - } - - #[test] - fn search_pages_preserve_relevance_and_selection() { - let mut view = View::new("Search".into(), ViewSource::Search); - let mut first = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#, - ) - .unwrap(); - first.has_more = true; - view.accept_page(first, false).unwrap(); - view.selection.select(Some(1)); - let mut next = youtube::search_results( - r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Duplicate"},{"id":"l-GaaivVJCE","title":"Relevant","timestamp":50},{"id":"l-GaaivVJCE","title":"Repeated"},{"id":"aaaaaaaaaaa","title":"Later","timestamp":300}]}"#, - ) - .unwrap(); - next.has_more = true; - view.accept_page(next, false).unwrap(); - assert_eq!( - view.videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"] - ); - assert_eq!(view.videos[1].title, "Second"); - assert_eq!(view.selection.selected(), Some(1)); - assert_eq!(view.next_start, PAGE_SIZE * 2 + 1); - assert!(view.has_more); - } - - #[test] - fn many_channel_feed_consumes_only_sixty_requested_rows() { - let mut app = app(&["tubular"]); - app.view_index = 1; - app.feed_channels = (0..75) - .map(|index| format!("https://www.youtube.com/@creator{index}")) - .collect(); - let view = &mut app.views[1]; - view.feed_requests = view.feed_cursor.plan(app.feed_channels.len()).unwrap(); - assert_eq!( - view.feed_requests - .iter() - .map(|request| request.count) - .sum::(), - PAGE_SIZE - ); - assert_eq!(view.feed_requests.len(), PAGE_SIZE); - let output = view - .feed_requests - .iter() - .map(|request| { - serde_json::json!({"entries":[{ - "id":format!("channel{:04}", request.channel), - "title":"Upload", - "timestamp":request.channel, - }]}) - .to_string() - }) - .collect::>() - .join("\n"); - app.finish_page(Ok(output)); - assert_eq!(app.views[1].videos.len(), PAGE_SIZE); - assert!(app.views[1].has_more); - assert!(!app.can_prefetch()); - let next = app.views[1] - .feed_cursor - .plan(app.feed_channels.len()) - .unwrap(); - assert!( - next.iter() - .any(|request| request.channel >= PAGE_SIZE && request.start == 1) - ); - assert_eq!( - next.iter().map(|request| request.count).sum::(), - PAGE_SIZE - ); - app.views[1].feed_requests = next.clone(); - app.views[1].prefetch_requested = true; - app.finish_page(Ok(r#"{"entries":[]}"#.into())); - assert!(app.views[1].page_error.is_some()); - assert_eq!(app.views[1].videos.len(), PAGE_SIZE); - assert_eq!(app.views[1].next_start, PAGE_SIZE + 1); - assert_eq!( - app.views[1] - .feed_cursor - .plan(app.feed_channels.len()) - .unwrap(), - next - ); - assert!(app.views[1].prefetch_requested); - } - - #[test] - fn refresh_keeps_rows_until_a_valid_replacement_arrives() { - let mut app = app(&["tubular"]); - app.view_index = 1; - // Duplicate URLs fail request creation before any extractor is spawned. - app.feed_channels = vec!["fixture".into(), "fixture".into()]; - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old"},{"id":"rQ_J9WH6CGk","title":"Selected"}]} -{"entries":[]}"#, - false, - ); - app.views[1].selection.select(Some(1)); - app.load_view(); - assert_eq!(app.views[1].videos[1].title, "Selected"); - assert_eq!(app.views[1].selection.selected(), Some(1)); - assert!(app.views[1].page_error.is_some()); - assert_eq!(app.views[1].next_start, 1); - - app.finish_page(Ok("not JSON".into())); - assert_eq!(app.views[1].videos[1].title, "Selected"); - assert_eq!(app.views[1].selection.selected(), Some(1)); - - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Updated"}]} -{"entries":[]}"#, - true, - ); - assert_eq!(app.views[1].videos.len(), 1); - assert_eq!(app.views[1].videos[0].title, "Updated"); - assert_eq!(app.views[1].selection.selected(), Some(0)); - assert_eq!(app.views[1].next_start, PAGE_SIZE + 1); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Next page"}]} -{"entries":[]}"#, - false, - ); - assert_eq!(app.views[1].videos[0].title, "Updated"); - assert_eq!(app.views[1].videos[1].title, "Next page"); - - app.load_view(); - assert_eq!(app.views[1].videos[0].title, "Updated"); - finish_feed_fixture(&mut app, "{\"entries\":[]}\n{\"entries\":[]}", false); - assert!(app.views[1].videos.is_empty()); - assert_eq!(app.views[1].selection.selected(), None); - } - - #[test] - fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() { - let mut app = app(&["tubular"]); - app.view_index = 1; - app.feed_channels = vec![ - "https://www.youtube.com/@one".into(), - "https://www.youtube.com/@two".into(), - ]; - // Keep both channels active despite a short visible page. - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Undated"}]} -{"entries":[{"id":"l-GaaivVJCE","title":"Middle","timestamp":200}]}"#, - true, - ); - assert_eq!( - app.views[1] - .videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] - ); - app.views[1].selection.select(Some(1)); - let description_id = app.views[1].videos[1].id.clone(); - app.preview = Some(PreviewMode::Info); - app.remember_description("l-GaaivVJCE".into(), "Keep cached preview".into()); - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Newer","timestamp":300}]} -{"entries":[]}"#, - false, - ); - assert_eq!( - app.views[1] - .videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] - ); - assert_eq!(app.views[1].selection.selected(), Some(2)); - assert_eq!(app.views[1].videos[2].title, "Old"); - app.finish_description( - description_id, - Ok(r#"{"id":"jNQXAC9IVRw","description":"Selected upload description"}"#.into()), - ); - assert_eq!( - cached_description(&app.descriptions, "jNQXAC9IVRw"), - Some("Selected upload description") - ); - assert_eq!( - cached_description(&app.descriptions, "l-GaaivVJCE"), - Some("Keep cached preview") - ); - assert_eq!(cached_description(&app.descriptions, "SLV9AJxnb1U"), None); - assert_eq!(app.preview, Some(PreviewMode::Info)); - assert!(!app.views[1].has_more); - } - - #[test] - fn short_undated_or_invalid_feed_pages_require_new_forward_navigation() { - for visible in [0, 1, 19] { - let mut app = app(&["tubular"]); - app.view_index = 1; - app.feed_channels - .push("https://www.youtube.com/@jawed".into()); - let mut entries: Vec<_> = (0..visible) - .map(|index| { - serde_json::json!({ - "id":format!("unknown{index:04}"), "title":"Undated", - }) - }) - .collect(); - entries.resize(PAGE_SIZE - 1, serde_json::Value::Null); - entries.push(serde_json::json!({"id":"bad", "title":"Invalid ID"})); - finish_feed_fixture( - &mut app, - &serde_json::json!({"entries":entries}).to_string(), - false, - ); - assert_eq!(app.views[1].videos.len(), visible); - assert!(app.views[1].has_more); - assert!(!app.can_prefetch()); - app.update(); - assert!(app.job.is_none()); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(app.can_prefetch()); - assert_eq!( - app.views[1].selection.selected(), - if visible == 0 { - None - } else { - Some((visible - 1).min(1)) - } - ); - finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); - assert!(app.views[1].has_more); - assert!(!app.can_prefetch()); - app.key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)); - assert!(app.views[1].prefetch_requested); - let next = app.views[1].feed_cursor.plan(1).unwrap(); - app.finish_page(Err("Continuation failed".into())); - assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next); - assert!(!app.can_prefetch()); - app.views[1].reset(); - assert!(app.views[1].feed_requests.is_empty()); - assert!(!app.views[1].prefetch_requested); - assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1); - } - } - - #[test] - fn feed_cursors_and_staged_requests_survive_cancellation_and_history_independently() { - let mut app = app(&["tubular"]); - app.feed_channels - .push("https://www.youtube.com/@one".into()); - app.config - .categories - .insert("Two".into(), vec!["https://www.youtube.com/@two".into()]); - app.views - .push(View::new("Two".into(), ViewSource::Category("Two".into()))); - let category = app.views.len() - 1; - app.views[0].loaded = true; - for index in [1, category] { - app.view_index = index; - finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); - } - app.view_index = 1; - app.views[1].prefetch_requested = true; - app.views[1].feed_requests = app.views[1].feed_cursor.plan(1).unwrap(); - let cancelled = app.views[1].feed_requests.clone(); - app.switch_view(category); - finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); - assert_eq!( - app.views[category].feed_cursor.plan(1).unwrap()[0].start, - PAGE_SIZE * 2 + 1 - ); - app.back_view(); - assert_eq!(app.view_index, 1); - assert_eq!(app.views[1].feed_requests, cancelled); - assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), cancelled); - assert!(app.views[1].prefetch_requested); - app.forward_view(); - assert_eq!(app.view_index, category); - assert!(!app.views[category].prefetch_requested); - assert_eq!( - app.views[category].feed_cursor.plan(1).unwrap()[0].start, - PAGE_SIZE * 2 + 1 - ); - } - - #[test] - fn category_membership_reordering_keeps_cursor_request_intent_and_cache() { - let mut app = App::new( - Options::parse_from(["tubular"]), - PathBuf::new(), - config::Config { - categories: [( - "Creators".into(), - vec![ - "https://www.youtube.com/@two".into(), - "https://www.youtube.com/@one".into(), - ], - )] - .into(), - ..Default::default() - }, - ); - app.view_index = 2; - finish_feed_fixture( - &mut app, - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, - true, - ); - app.views[2].query = " draft robotics ".into(); - app.views[2].submitted_query = "robotics".into(); - app.remember_description("jNQXAC9IVRw".into(), "Retained".into()); - app.views[2].feed_requests = app.views[2].feed_cursor.plan(2).unwrap(); - app.views[2].prefetch_requested = true; - let requests = app.views[2].feed_requests.clone(); - let mut categories = app.config.categories.clone(); - categories.get_mut("Creators").unwrap().reverse(); - app.apply_config(config::Config { - categories, - ..Default::default() - }); - assert_eq!(app.views[2].feed_requests, requests); - assert_eq!(app.views[2].feed_cursor.plan(2).unwrap(), requests); - assert!(app.views[2].prefetch_requested); - assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw"); - assert_eq!(app.views[2].selection.selected(), Some(0)); - assert_eq!(app.views[2].query, " draft robotics "); - assert_eq!(app.views[2].submitted_query, "robotics"); - assert_eq!( - cached_description(&app.descriptions, "jNQXAC9IVRw"), - Some("Retained") - ); - let mut categories = app.config.categories.clone(); - categories - .get_mut("Creators") - .unwrap() - .push("https://www.youtube.com/@three".into()); - app.apply_config(config::Config { - categories, - ..Default::default() - }); - assert!(!app.views[2].loaded); - assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw"); - assert!(app.views[2].feed_requests.is_empty()); - assert!(!app.views[2].prefetch_requested); - assert_eq!(app.views[2].next_start, 1); - assert_eq!(app.views[2].query, " draft robotics "); - assert_eq!(app.views[2].submitted_query, "robotics"); - assert!( - app.views[2] - .feed_cursor - .plan(3) - .unwrap() - .iter() - .all(|request| request.start == 1) - ); - } - - #[test] - fn page_failures_and_view_switches_preserve_the_successful_cursor_until_reset() { - let mut app = app(&["tubular", "rust"]); - app.views[0].submitted_query = "rust".into(); - app.finish_page(Err("First request failed".into())); - assert_eq!(app.views[0].next_start, 1); - assert!(!app.views[0].loaded); - assert!(app.views[0].page_error.is_some()); - app.switch_view(0); - app.update(); - assert!(app.job.is_none()); - app.views[0].reset(); - let entries: Vec<_> = (0..PAGE_SIZE) - .map(|index| { - serde_json::json!({ - "id": format!("search{index:05}"), - "title": "Result", - }) - }) - .collect(); - app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string())); - assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); - app.views[0].selection.select(Some(17)); - app.remember_description(app.views[0].videos[17].id.clone(), "Cached.".into()); - app.switch_view(1); - app.switch_view(0); - assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); - assert_eq!(app.views[0].selection.selected(), Some(17)); - app.finish_page(Ok("not JSON".into())); - app.update(); - assert!(app.job.is_none()); - assert_eq!(app.views[0].videos.len(), PAGE_SIZE); - assert_eq!( - cached_description(&app.descriptions, &app.views[0].videos[17].id), - Some("Cached.") - ); - assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); - assert!(!app.views[0].needs_page()); - let view = &mut app.views[0]; - view.feed_requests = view.feed_cursor.plan(1).unwrap(); - view.prefetch_requested = true; - view.reset(); - assert!(view.feed_requests.is_empty()); - assert!(!view.feed_cursor.has_more()); - assert!(!view.prefetch_requested); - assert_eq!(view.next_start, 1); - assert!(!view.loaded); - assert!(!view.has_more); - assert!(view.page_error.is_none()); - } - - #[test] - fn prefetch_only_runs_near_the_end_in_an_unobstructed_normal_view() { - let mut app = app(&["tubular"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - let mut page = - youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Result"}]}"#) - .unwrap(); - page.has_more = true; - app.views[0].accept_page(page, false).unwrap(); - assert!(!app.can_prefetch()); - app.mode = Mode::Normal; - assert!(!app.can_prefetch()); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(app.can_prefetch()); - app.preview = Some(PreviewMode::Description); - assert!(!app.can_prefetch()); - app.preview = Some(PreviewMode::Info); - assert!(!app.can_prefetch()); - app.preview = None; - app.category_dialog = Some(CategoryDialog { - name: None, - ..CategoryDialog::new( - CategoryTarget::Channel { - channel: String::new(), - handle: String::new(), - }, - String::new(), - BTreeMap::new(), - ) - }); - assert!(!app.can_prefetch()); - app.category_dialog = None; - let entries: Vec<_> = (0..PAGE_SIZE) - .map(|index| { - serde_json::json!({ - "id": format!("search{index:05}"), - "title": "Next result", - }) - }) - .collect(); - app.views[0].reset(); - app.views[0] - .accept_page( - youtube::search_results(&serde_json::json!({"entries": entries}).to_string()) - .unwrap(), - false, - ) - .unwrap(); - assert_eq!(app.views[0].selection.selected(), Some(0)); - assert!(!app.can_prefetch()); - let threshold = app.views[0].videos.len() - PAGE_SIZE / 3; - app.views[0].selection.select(Some(threshold - 1)); - assert!(!app.can_prefetch()); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert!(app.can_prefetch()); - assert!(app.job.is_none()); - assert_eq!(app.views[0].selection.selected(), Some(threshold)); - app.views[0].loaded = false; - assert!(!app.can_prefetch()); - app.views[0].loaded = true; - app.views[0].has_more = false; - assert!(!app.can_prefetch()); - app.views[0].has_more = true; - app.help_popup = Some(HelpPopup::default()); - assert!(!app.can_prefetch()); - app.help_popup = None; - app.views[0].selection.select(None); - assert!(!app.can_prefetch()); - app.views[0].selection.select(Some(threshold)); - app.views[0].page_error = Some("Stop retrying".into()); - assert!(!app.can_prefetch()); - app.views[0].page_error = None; - app.views[0].videos.clear(); - app.views[0].selection.select(None); - assert!(app.can_prefetch()); - } - - #[test] - fn input_cursor_tracks_display_width_and_stays_inside_scrolled_fields() { - use ratatui::{Terminal, backend::TestBackend}; - - let mut app = app(&["tubular"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - let mut terminal = Terminal::new(TestBackend::new(24, 20)).unwrap(); - for (text, x) in [ - ("", 1), - ("ab", 3), - ("日本語", 7), - ("e\u{301}", 2), - ("abcdefghijklmnopqrstuvwxyz", 22), - ] { - app.views[0].query = text.into(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into()); - } - app.category_dialog = Some(CategoryDialog { - name: Some(String::new()), - ..CategoryDialog::new( - CategoryTarget::Channel { - channel: String::new(), - handle: String::new(), - }, - String::new(), - BTreeMap::new(), - ) - }); - for (text, x) in [("", 3), ("日本語", 9), ("abcdefghijklmnopqrstuvwxyz", 20)] { - app.category_dialog.as_mut().unwrap().name = Some(text.into()); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert_eq!(terminal.get_cursor_position().unwrap(), (x, 7).into()); - } - } - - #[test] - fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() { - for tag_input in [false, true] { - for (input, code, modifiers, expected) in [ - ( - "first 日本語 ", - KeyCode::Backspace, - KeyModifiers::ALT, - "first ", - ), - ( - "first\u{3000}cafe\u{301} ", - KeyCode::Char('w'), - KeyModifiers::CONTROL, - "first\u{3000}", - ), - ("word", KeyCode::Backspace, KeyModifiers::ALT, ""), - (" \u{3000} ", KeyCode::Backspace, KeyModifiers::ALT, ""), - ("", KeyCode::Backspace, KeyModifiers::ALT, ""), - ("first 日本語", KeyCode::Backspace, KeyModifiers::SUPER, ""), - ( - "first 日本語", - KeyCode::Backspace, - KeyModifiers::SUPER | KeyModifiers::ALT, - "", - ), - ( - "first 日本語", - KeyCode::Char('u'), - KeyModifiers::CONTROL, - "", - ), - ( - "first 日本語", - KeyCode::Backspace, - KeyModifiers::NONE, - "first 日本", - ), - ] { - let mut app = app(&["tubular"]); - if tag_input { - app.mode = Mode::Normal; - app.category_dialog = Some(CategoryDialog { - name: Some(input.into()), - ..CategoryDialog::new( - CategoryTarget::Channel { - channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A" - .into(), - handle: "@jawed".into(), - }, - "jawed".into(), - BTreeMap::new(), - ) - }); - } else { - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.views[0].query = input.into(); - } - app.key(KeyEvent::new(code, modifiers)); - let edited = app - .category_dialog - .as_ref() - .and_then(|dialog| dialog.name.as_deref()) - .unwrap_or(&app.views[0].query); - assert_eq!( - edited, expected, - "{tag_input}: {input:?} {code:?} {modifiers:?}" - ); - assert!(app.job.is_none()); - assert_eq!( - app.mode, - if tag_input { - Mode::Normal - } else { - Mode::Insert - } - ); - } - } - let mut app = app(&["tubular"]); - app.views[0].query = "keep this query".into(); - app.mode = Mode::Normal; - for (code, modifiers) in [ - (KeyCode::Backspace, KeyModifiers::ALT), - (KeyCode::Backspace, KeyModifiers::SUPER), - (KeyCode::Char('w'), KeyModifiers::CONTROL), - ] { - app.key(KeyEvent::new(code, modifiers)); - assert_eq!(app.views[0].query, "keep this query"); - } - } - - #[test] - fn modal_keys_edit_or_navigate_without_submitting_on_escape() { - let mut app = app(&["tubular"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.views[0].videos = vec![ - Video { - id: "jNQXAC9IVRw".into(), - title: "Me at the zoo".into(), - channel: None, - channel_id: None, - uploader_id: None, - timestamp: None, - publication_date: None, - availability: None, - }, - Video { - id: "rQ_J9WH6CGk".into(), - title: "Rust course".into(), - channel: None, - channel_id: None, - uploader_id: None, - timestamp: None, - publication_date: None, - availability: None, - }, - ]; - app.views[0].selection.select(Some(0)); - - for c in ['j', 'k', 'q'] { - assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))); - } - app.update(); - assert_eq!(app.views[0].query, "jkq"); - assert_eq!(app.views[0].selection.selected(), Some(0)); - assert!(app.job.is_none()); - - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.mode, Mode::Normal); - assert!(app.job.is_none()); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); - assert_eq!(app.views[0].selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert_eq!(app.mode, Mode::Insert); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!(app.mode, Mode::Normal); - assert!(app.views[0].query.is_empty()); - assert!(app.job.is_none()); - } - - #[test] - fn category_dialog_is_modal_and_views_keep_independent_selections() { - let mut app = app(&["tubular", "rust"]); - let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#; - app.views[0].videos = youtube::search_results(videos).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(1)); - // The selected channel is unavailable; do not save a guessed display-name identity. - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().error.is_some()); - for c in ['j', 'k', 'q', 'i', 't', 'd', ' '] { - assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))); - } - assert_eq!( - app.category_dialog.as_ref().unwrap().name.as_deref(), - Some("jkqitd ") - ); - assert_eq!(app.views[0].query, "rust"); - assert_eq!(app.views[0].selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().name.is_none()); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().name.is_none()); - assert!(app.category_dialog.as_ref().unwrap().tags["A"]); - assert!(app.config.categories.is_empty()); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - assert!(!app.category_dialog.as_ref().unwrap().tags["A"]); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!app.category_dialog.as_ref().unwrap().tags["A"]); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - assert_eq!(app.mode, Mode::Normal); - assert!(app.config.categories.is_empty()); - - let rust = vec!["https://www.youtube.com/@RustVideos".into()]; - app.apply_config(config::Config { - keys: Default::default(), - categories: [("Rust".into(), rust.clone())].into(), - channels: BTreeMap::new(), - pinned_categories: vec!["Rust".into()], - }); - app.views[0].selection.select(Some(1)); - app.views[1].videos = feed_fixture(videos).unwrap().videos; - app.views[1].loaded = true; - app.views[1].selection.select(Some(0)); - app.views[2].videos = feed_fixture(videos).unwrap().videos; - app.views[2].loaded = true; - app.views[2].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 1); - assert_eq!(app.views[1].name, "Feed"); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 2); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - assert_eq!(app.views[2].selection.selected(), Some(1)); - assert_eq!(app.views[1].selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.view_index, 1); - assert_eq!(app.views[1].selection.selected(), Some(0)); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.view_index, 0); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - app.apply_config(config::Config { - keys: Default::default(), - categories: [ - ( - "Books".into(), - vec!["https://www.youtube.com/@example".into()], - ), - ("Rust".into(), rust), - ] - .into(), - channels: BTreeMap::new(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 3); - assert_eq!(app.views[3].name, "Rust"); - assert_eq!(app.views[3].selection.selected(), Some(1)); - assert!(app.views[3].loaded); - app.apply_config(config::Config { - keys: Default::default(), - categories: [("Rust".into(), vec![])].into(), - channels: BTreeMap::new(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.views[app.view_index].name, "Rust"); - assert!(!app.views[app.view_index].loaded); - assert!(app.job.is_none()); - app.load_page(); - assert!(app.views[app.view_index].videos.is_empty()); - assert!(app.views[app.view_index].selection.selected().is_none()); - assert!(app.views[app.view_index].loaded); - } - - #[test] - fn feed_cache_tracks_subscriptions_not_tag_memberships() { - let registered = "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA"; - let direct = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; - let added = "https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"; - let mut app = App::new( - Options::parse_from(["tubular", "rust"]), - PathBuf::new(), - config::Config { - keys: Default::default(), - channels: [("@RustVideos".into(), registered.into())].into(), - categories: [ - ("Direct".into(), vec![direct.into()]), - ("Tagged".into(), vec![registered.into()]), - ] - .into(), - pinned_categories: Vec::new(), - }, - ); - let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#; - assert_eq!(app.feed_channels, vec![direct, registered]); - app.views[1].videos = feed_fixture(videos).unwrap().videos; - app.views[1].selection.select(Some(1)); - app.views[1].loaded = true; - app.views[1].next_start = 41; - app.views[1].has_more = true; - app.views[1].feed_requests = app.views[1].feed_cursor.plan(2).unwrap(); - app.views[1].prefetch_requested = true; - app.views[1].page_error = Some("Retained page error".into()); - app.remember_description( - app.views[1].videos[1].id.clone(), - "Retained description".into(), - ); - app.views[3].videos = feed_fixture(videos).unwrap().videos; - app.views[3].selection.select(Some(0)); - app.views[3].loaded = true; - app.views[3].next_start = 61; - app.views[3].has_more = true; - app.views[3].feed_requests = app.views[3].feed_cursor.plan(1).unwrap(); - app.views[3].prefetch_requested = true; - app.views[3].page_error = Some("Tag page failed".into()); - app.switch_view(1); - - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Direct".into(), vec![direct.into()]), - ("Renamed".into(), vec![registered.into()]), - ("Tagged".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 1); - assert!(app.views[1].loaded); - assert_eq!(app.views[1].videos[1].id, "rQ_J9WH6CGk"); - assert_eq!(app.views[1].selection.selected(), Some(1)); - assert_eq!(app.views[1].next_start, 41); - assert!(app.views[1].has_more); - assert_eq!(app.views[1].feed_requests.len(), 2); - assert!(app.views[1].prefetch_requested); - assert_eq!( - app.views[1].page_error.as_deref(), - Some("Retained page error") - ); - assert_eq!( - cached_description(&app.descriptions, &app.views[1].videos[1].id), - Some("Retained description") - ); - assert!(!app.views[4].loaded); - assert_eq!(app.views[4].next_start, 1); - assert!(!app.views[4].has_more); - assert!(app.views[4].feed_requests.is_empty()); - assert!(!app.views[4].prefetch_requested); - assert!(app.views[4].page_error.is_none()); - - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Direct".into(), vec![direct.into()]), - ("Renamed".into(), vec![]), - ("Tagged".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.feed_channels, vec![direct, registered]); - assert!(app.views[1].loaded); - assert_eq!(app.views[1].videos.len(), 2); - assert_eq!(app.views[1].selection.selected(), Some(1)); - assert_eq!(app.views[1].next_start, 41); - assert!(app.views[1].has_more); - assert_eq!(app.views[1].feed_requests.len(), 2); - assert!(app.views[1].prefetch_requested); - assert!(app.views[1].page_error.is_some()); - - let mut channels = app.config.channels.clone(); - channels.insert("@YouTube".into(), added.into()); - app.apply_config(config::Config { - keys: Default::default(), - channels, - categories: app.config.categories.clone(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.feed_channels, vec![direct, added, registered]); - assert!(!app.views[1].loaded); - assert_eq!(app.views[1].next_start, 1); - assert!(!app.views[1].has_more); - assert!(app.views[1].feed_requests.is_empty()); - assert!(!app.views[1].prefetch_requested); - assert!(app.views[1].page_error.is_none()); - assert_eq!(app.view_index, 1); - assert!(app.job.is_none()); - - app.views[1].videos = feed_fixture(videos).unwrap().videos; - app.views[1].selection.select(Some(1)); - app.views[1].loaded = true; - let mut channels = app.config.channels.clone(); - channels.remove("@YouTube"); - app.apply_config(config::Config { - keys: Default::default(), - channels, - categories: app.config.categories.clone(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.feed_channels, vec![direct, registered]); - assert!(!app.views[1].loaded); - assert_eq!(app.view_index, 1); - assert!(app.job.is_none()); - } - - #[test] - fn builtins_and_same_named_tags_keep_distinct_identities() { - let mut app = app(&["tubular", "rust"]); - app.apply_config(config::Config { - keys: Default::default(), - channels: [( - "@RustVideos".into(), - "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(), - )] - .into(), - categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), - pinned_categories: Vec::new(), - }); - let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; - for (index, view) in app.views.iter_mut().enumerate() { - view.videos = feed_fixture(videos).unwrap().videos; - view.loaded = true; - view.selection.select(Some(usize::from(index % 2 == 0))); - } - app.switch_view(1); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Alpha".into(), vec![]), - ("Feed".into(), vec![]), - ("Search".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 1); - assert_eq!(app.views[1].selection.selected(), Some(0)); - assert_eq!(app.views[3].name, "Feed"); - assert_eq!(app.views[3].selection.selected(), Some(1)); - assert_eq!(app.views[0].selection.selected(), Some(1)); - assert_eq!(app.views[4].selection.selected(), Some(0)); - - app.switch_view(0); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 0); - app.switch_view(2); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Alpha".into(), vec![]), - ("Feed".into(), vec![]), - ("Search".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 3); - assert_eq!(app.views[3].selection.selected(), Some(1)); - - app.switch_view(4); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Alpha".into(), vec![]), - ("Beta".into(), vec![]), - ("Feed".into(), vec![]), - ("Search".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 5); - assert_eq!(app.views[5].selection.selected(), Some(0)); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [ - ("Alpha".into(), vec![]), - ("Beta".into(), vec![]), - ("Feed".into(), vec![]), - ] - .into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 0); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.switch_view(4); - app.apply_config(config::Config { - keys: Default::default(), - channels: app.config.channels.clone(), - categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), - pinned_categories: Vec::new(), - }); - assert_eq!(app.view_index, 0); - assert_eq!(app.views[1].name, "Feed"); - assert_eq!(app.views[1].selection.selected(), Some(0)); - assert!(app.job.is_none()); - } - - #[test] - fn empty_feed_is_informative_and_navigation_wraps_without_extraction() { - let mut app = app(&["tubular"]); - assert_eq!(app.views.len(), 2); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)); - assert_eq!(app.view_index, 1); - assert!(app.views[1].loaded); - assert!(app.views[1].videos.is_empty()); - assert_eq!(app.views[1].selection.selected(), None); - assert!(app.job.is_none()); - app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); - assert_eq!(app.view_index, 0); - app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); - assert_eq!(app.views[app.view_index].source, ViewSource::Feed); - assert!(app.job.is_none()); - } - - #[test] - fn channel_tags_stage_memberships_and_commit_only_from_the_list() { - let path = std::env::temp_dir().join(format!( - "tubular-tag-editor-test-{}.toml", - std::process::id() - )); - struct TestFile(PathBuf); - impl Drop for TestFile { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.0); - } - } - std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - .unwrap(); - let _file = TestFile(path.clone()); - let original = "[categories]\nAlias = [\"@RustVideos\"]\nCanonical = [\"UCaYhcUwRBNscFNUKTjgPFiA\"]\nEmpty = []\n"; - std::fs::write(&path, original).unwrap(); - let mut app = app(&["tubular", "rust"]); - app.config_path = path.clone(); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"}]}"#, - ) - .unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert_eq!( - app.category_dialog.as_ref().unwrap().tags, - [ - ("Alias".into(), true), - ("Canonical".into(), true), - ("Empty".into(), false), - ] - .into() - ); - app.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().tags["Alias"]); - app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); - for c in "Empty".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().error.is_some()); - app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); - for c in " Added ".chars() { - app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); - } - app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.category_dialog.as_ref().unwrap().selection.selected(), - Some(0) - ); - assert!(app.category_dialog.as_ref().unwrap().tags["Added"]); - assert_eq!(std::fs::read_to_string(&path).unwrap(), original); - app.category_dialog.as_mut().unwrap().error = Some("Previous save failed".into()); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - assert!(app.category_dialog.as_ref().unwrap().error.is_none()); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - let saved = config::load(&path).unwrap(); - let canonical = vec!["https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".to_owned()]; - assert!(saved.categories["Alias"].is_empty()); - assert!(saved.categories["Added"].is_empty()); - assert_eq!(saved.categories["Canonical"], canonical); - assert_eq!(saved.categories["Empty"], canonical); - let committed = std::fs::read_to_string(&path).unwrap(); - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(std::fs::read_to_string(&path).unwrap(), committed); - std::fs::write(&path, "[categories]\n").unwrap(); - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert!( - app.category_dialog - .as_ref() - .unwrap() - .selection - .selected() - .is_none() - ); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.category_dialog.is_none()); - assert!(config::load(&path).unwrap().categories.is_empty()); - } - - #[test] - fn preview_cycles_without_reloading_and_selection_resets_scrolling() { - use ratatui::{Terminal, backend::TestBackend}; - - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"9bZkp7q19f0","title":"Second"}]}"#, - ).unwrap().videos; - // Invalid fixture IDs keep thumbnail failure deterministic without invoking curl. - app.views[0].videos[0].id = "first".into(); - app.views[0].videos[1].id = "second".into(); - app.remember_description("first".into(), "First paragraph\n\n".repeat(40)); - app.remember_description("second".into(), "Second paragraph\n\n".repeat(40)); - app.views[0].selection.select(Some(0)); - app.views[0].loaded = true; - let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); - let press = |app: &mut App, code| { - app.key(KeyEvent::new(code, KeyModifiers::NONE)); - }; - press(&mut app, KeyCode::Char('t')); - assert_eq!(app.preview, None); - press(&mut app, KeyCode::Char('d')); - assert_eq!(app.preview, Some(PreviewMode::Info)); - assert!(app.description_job.is_none()); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let info = terminal.backend().buffer().clone(); - press(&mut app, KeyCode::Char('d')); - assert_eq!(app.preview, Some(PreviewMode::Description)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - press(&mut app, KeyCode::PageDown); - assert!(app.description_scroll > 0); - let scroll = app.description_scroll; - press(&mut app, KeyCode::Char('d')); - assert_eq!(app.preview, Some(PreviewMode::Info)); - assert_eq!(app.description_scroll, scroll); - assert!(app.description_job.is_none()); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - // Includes the failed thumbnail text: cancellation would remove that image state. - assert_eq!(terminal.backend().buffer(), &info); - press(&mut app, KeyCode::Char('d')); - press(&mut app, KeyCode::Char('j')); - assert_eq!(app.preview, Some(PreviewMode::Description)); - assert_eq!(app.views[0].selection.selected(), Some(1)); - assert_eq!(app.description_scroll, 0); - assert_eq!(app.description_viewport, None); - assert!(app.description_error.is_none()); - assert!(app.description_job.is_none()); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let buffer = terminal.backend().buffer(); - let popup = preview_area(buffer.area); - let title = &buffer[(popup.x + 2, popup.y + 2)]; - assert_eq!(title.symbol(), "S"); - assert!(title.modifier.contains(ratatui::style::Modifier::BOLD)); - press(&mut app, KeyCode::Down); - assert_eq!(app.description_scroll, 1); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); - assert_eq!(app.description_scroll, 2); - assert_eq!(app.views[0].selection.selected(), Some(1)); - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert_eq!(app.description_scroll, 1); - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); - assert_eq!(app.description_scroll, 0); - app.description_scroll = app.description_max_scroll; - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); - assert_eq!(app.description_scroll, app.description_max_scroll); - assert_eq!(app.views[0].selection.selected(), Some(1)); - press(&mut app, KeyCode::Char('d')); - press(&mut app, KeyCode::Up); - assert_eq!(app.views[0].selection.selected(), Some(0)); - assert_eq!(app.description_scroll, 0); - press(&mut app, KeyCode::Esc); - assert_eq!(app.preview, None); - assert_eq!( - cached_description(&app.descriptions, "first"), - Some("First paragraph\n\n".repeat(40).as_str()) - ); - press(&mut app, KeyCode::Char('i')); - press(&mut app, KeyCode::Char('t')); - press(&mut app, KeyCode::Char('d')); - assert_eq!(app.views[0].query, "td"); - assert_eq!(app.preview, None); - } - - #[test] - fn full_description_scrolls_padded_metadata_and_body_together() { - use ratatui::{Terminal, backend::TestBackend, style::Modifier}; - - let mut app = app(&["tubular"]); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Selected video","channel":"Creator","upload_date":"20050423"}]}"#, - ).unwrap().videos; - app.views[0].selection.select(Some(0)); - app.preview = Some(PreviewMode::Description); - app.remember_description( - "jNQXAC9IVRw".into(), - (0..100).map(|i| format!("Body line {i:03}\n")).collect(), - ); - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let before = terminal.backend().buffer().clone(); - let popup = preview_area(before.area); - let x = popup.x + 2; - let title_y = popup.y + 2; - let body_y = (title_y..popup.bottom() - 1) - .find(|&y| before[(x, y)].symbol() == "B") - .expect("description is visible below metadata"); - for x in popup.x + 1..popup.right() - 1 { - assert_eq!(before[(x, popup.y + 1)].symbol(), " "); - } - assert!(before[(x, title_y)].modifier.contains(Modifier::BOLD)); - assert!(before[(x, title_y + 2)].modifier.contains(Modifier::DIM)); - assert!(!before[(x, body_y)].modifier.contains(Modifier::DIM)); - assert_eq!( - app.description_viewport, - Some((popup.width - 4, popup.height - 2)) - ); - app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); - terminal - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let after = terminal.backend().buffer(); - assert_eq!(after[(x, title_y - 1)].symbol(), "S"); - assert!(after[(x, title_y - 1)].modifier.contains(Modifier::BOLD)); - assert_eq!(after[(x, title_y + 1)].symbol(), "C"); - assert!(after[(x, title_y + 1)].modifier.contains(Modifier::DIM)); - let row = |buffer: &ratatui::buffer::Buffer, y| -> String { - (x..popup.right() - 2) - .map(|x| buffer[(x, y)].symbol()) - .collect() - }; - assert_eq!(row(&before, body_y).trim_end(), "Body line 000"); - assert_eq!(row(after, body_y).trim_end(), "Body line 001"); - for y in popup.y + 1..popup.bottom() - 1 { - assert_eq!(after[(popup.x + 1, y)].symbol(), " "); - assert_eq!(after[(popup.right() - 2, y)].symbol(), " "); - } - // A wrapped title cannot prevent reaching the description in a small pane. - let mut narrow = Terminal::new(TestBackend::new(12, 8)).unwrap(); - narrow - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - assert!(app.description_max_scroll > 0); - app.description_scroll = app.description_max_scroll; - narrow - .draw(|frame| app.draw(frame, Duration::ZERO)) - .unwrap(); - let narrow_popup = preview_area(narrow.backend().buffer().area); - let narrow_buffer = narrow.backend().buffer(); - let tail: String = (narrow_popup.y + 1..narrow_popup.bottom() - 1) - .flat_map(|y| { - (narrow_popup.x + 2..narrow_popup.right() - 2) - .map(move |x| narrow_buffer[(x, y)].symbol()) - }) - .collect(); - assert!(tail.contains("099"), "{tail}"); - } - - #[test] - fn search_without_a_handle_can_tag_a_registered_channel() { - let path = std::env::temp_dir().join(format!( - "tubular-missing-handle-{}.toml", - std::process::id() - )); - let original = - "[channels]\n'@ch.1676' = 'UChAQQ9at2MDlXjvQX3vL3Yw'\n[categories]\nJapanese = []\n"; - std::fs::write(&path, original).unwrap(); - let mut app = App::new( - Options::parse_from(["tubular"]), - path.clone(), - config::load(&path).unwrap(), - ); - app.views[0].videos = youtube::search_results( - r#"{"entries":[{"id":"nwxkg84mADI","title":"Hokkaido Vlog","channel":"敷嶋てとらch.","channel_id":"UChAQQ9at2MDlXjvQX3vL3Yw","uploader_id":null}]}"#, - ).unwrap().videos; - app.views[0].loaded = true; - app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert!(app.category_dialog.is_some(), "{}", app.status); - app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let saved = config::load(&path).unwrap(); - assert_eq!( - saved.categories["Japanese"], - ["https://www.youtube.com/channel/UChAQQ9at2MDlXjvQX3vL3Yw"] - ); - std::fs::remove_file(path).unwrap(); - } -} +#[path = "../tests/unit/app.rs"] +mod tests; diff --git a/src/miruro.rs b/src/miruro.rs index d644343..8c411cf 100644 --- a/src/miruro.rs +++ b/src/miruro.rs @@ -545,115 +545,5 @@ fn expand(table: &[Value], index: usize, depth: usize, budget: &mut usize) -> io } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() { - let sources = serde_json::json!({ - "episodeNumber": 1, - "config": { "sun": { "player": "native", "proxy": false } }, - "tracks": [{ "track": "ssub", "providers": [{ - "provider": "sun", - "servers": [{ - "headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" }, - "streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }] - }], - "subtitles": [{ - "file": "https://vault.keeply.top/english.jpeg", "format": "vtt", - "language": "en", "label": "English", "default": true - }] - }] }] - }); - let identity = Identity::new("catalog".into(), 1).unwrap(); - let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap(); - assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity); - assert_ne!( - Identity::new("catalog".into(), 2).unwrap().key(), - source.identity.key() - ); - for key in [ - "miruro:catalog:01", - "miruro:catalog:0", - "miruro:catalog:2147483648", - "miruro:catalog/:1", - ] { - assert!(Identity::from_key(key).is_err()); - } - for (pointer, value) in [ - ("/episodeNumber", serde_json::json!(49)), - ("/config/sun/proxy", serde_json::json!({"segments": true})), - ( - "/tracks/0/providers/0/servers/0/streams/0/url", - serde_json::json!("file:///secret"), - ), - ( - "/tracks/0/providers/0/servers/0/streams/0/embed", - serde_json::json!({"url": "https://embed.example"}), - ), - ( - "/tracks/0/providers/0/servers/0/headers/origin", - serde_json::json!("secret"), - ), - ( - "/tracks/0/providers/0/servers/0/headers/Referer", - serde_json::json!("secret\r\nX-Injected: true"), - ), - ] { - let mut invalid_sources = sources.clone(); - if let Some(field) = invalid_sources.pointer_mut(pointer) { - *field = value; - } else { - invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = - value; - } - let error = - SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1") - .unwrap_err(); - assert!(!error.to_string().contains("secret")); - } - let mut invalid_source = source; - invalid_source - .subtitles - .push(invalid_source.subtitles[0].clone()); - assert!(invalid_source.validate().is_err()); - invalid_source.subtitles[1].default = false; - invalid_source.subtitles[1].format = "srt".into(); - assert!(invalid_source.validate().is_err()); - } - - #[test] - fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() { - let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#; - let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#; - let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#; - let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap(); - assert_eq!(detail.media["episode_count"], 12); - assert_eq!(detail.episodes[0]["episode_number"], 1); - assert_eq!(detail.episodes[0]["absolute_number"], 49); - assert_eq!(detail.episodes[0]["title"], "Locusts"); - assert!(detail.episodes[0]["canon_type"].is_null()); - for chunk in [ - "", // The promised episodes never arrive. - r#"{"type":"chunk","id":9,"error":["failed"]}"#, - r#"{"type":"chunk","id":9,"data":[[999]]}"#, - r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle. - r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#, - ] { - assert_eq!( - decode_detail(&format!("{initial}\n{chunk}")) - .err() - .unwrap() - .kind(), - io::ErrorKind::InvalidData - ); - } - assert_eq!( - decode_detail(r#"{"type":"redirect","location":"/"}"#) - .err() - .unwrap() - .kind(), - io::ErrorKind::NotFound - ); - } -} +#[path = "../tests/unit/miruro.rs"] +mod tests; diff --git a/src/miruro_browser.rs b/src/miruro_browser.rs index a29842f..02c15f5 100644 --- a/src/miruro_browser.rs +++ b/src/miruro_browser.rs @@ -273,74 +273,5 @@ pub fn series(media_id: &str, detail: Detail) -> io::Result { } #[cfg(test)] -mod tests { - use super::*; - #[test] - fn catalog_and_actual_episode_numbers_remain_distinct() { - let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null}); - let rows = catalog(vec![media.clone()]).unwrap(); - assert_eq!(rows[0].id, "opaque_ID"); - assert!(rows[0].metadata["episode_counts"]["sub"].is_null()); - assert!(catalog(vec![media.clone(), media.clone()]).is_err()); - let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500}); - let detail = || Detail { - media: media.clone(), - episodes: vec![episode.clone()], - }; - let data = series("opaque_ID", detail()).unwrap(); - assert_eq!(data.episodes.len(), 1); - assert_eq!(data.episodes[0].identity.episode, 3); - assert_eq!(data.episodes[0].identity.media_id, "opaque_ID"); - assert_eq!(data.episodes[0].duration_seconds, Some(1500)); - assert_eq!( - data.episodes[0].synopsis.as_deref(), - Some("Actual synopsis") - ); - assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none()); - assert!(series("other", detail()).is_err()); - assert!( - series( - "opaque_ID", - Detail { - media: media.clone(), - episodes: vec![episode.clone(), episode] - } - ) - .is_err() - ); - assert!( - series( - "opaque_ID", - Detail { - media, - episodes: vec![serde_json::json!({"absolute_number":49})] - } - ) - .is_err() - ); - assert!(slug("薬屋").is_err()); - assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title"); - assert_eq!(slug(" --Title-- ").unwrap(), "-title-"); - assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu"); - assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe"); - assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i"); - assert_eq!( - slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(), - "a-b-c" - ); - assert_eq!(slug("A\u{0085}B").unwrap(), "ab"); - assert!(slug("!!薬屋!!").is_err()); - assert_eq!( - title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(), - "AB" - ); - assert_eq!( - title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(), - "BC" - ); - assert_eq!( - title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(), - "" - ); - } -} +#[path = "../tests/unit/miruro_browser.rs"] +mod tests; diff --git a/src/miruro_ui.rs b/src/miruro_ui.rs index 063ad59..348c12b 100644 --- a/src/miruro_ui.rs +++ b/src/miruro_ui.rs @@ -2115,690 +2115,5 @@ pub fn run( } #[cfg(test)] -mod tests { - use super::*; - use ratatui::crossterm::event::KeyModifiers; - use std::{ - collections::BTreeMap, - fs, - io::Write, - sync::atomic::{AtomicU64, Ordering}, - }; - use tubular::miruro::{Detail, SourceDocument}; - - 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, - 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 source(video: &Video, token: &str) -> SourceDocument { - SourceDocument { - version: 1, - url: format!("https://vault.keeply.top/master.m3u8?token={token}"), - headers: BTreeMap::new(), - identity: Identity::from_key(&video.id).unwrap(), - title: Some(video.title.clone()), - subtitles: Vec::new(), - } - } - 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))); - } - fn sources(directory: &Directory) -> Vec { - let path = directory.0.join("playback-sessions"); - if !path.exists() { - return Vec::new(); - } - fs::read_dir(path) - .unwrap() - .map(|entry| { - serde_json::from_slice( - &fs::read(entry.unwrap().path().join("source.json")).unwrap(), - ) - .unwrap() - }) - .collect() - } - - #[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_series_and_sources() { - 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))); - let video = browser.screen().selected_video().unwrap(); - browser.open(); - let stale = browser.generation; - browser.history(false); - browser.accept( - stale, - Ok(Response::Source( - video.clone(), - Box::new(source(&video, "stale")), - )), - ); - assert!(sources(&directory).is_empty()); - browser.history(true); - browser.open(); - let stale = browser.generation; - browser.refresh(); - browser.accept( - stale, - Ok(Response::Source( - video.clone(), - Box::new(source(&video, "refreshed-away")), - )), - ); - assert!(sources(&directory).is_empty()); - } - - #[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.open(); - assert!(browser.pending.is_some()); - browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE)); - assert!(browser.state.is_watched(&episode.id)); - assert_eq!(browser.screen().len(), 0); - assert!(browser.pending.is_none()); - 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.open(); - assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id)); - 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 - .miruro_session(&episode, &source(&episode, "progress")) - .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 selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() { - 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.open(); - let stale = browser.generation; - browser.select(Action::VideoNext); - browser.accept( - stale, - Ok(Response::Source( - first.clone(), - Box::new(source(&first, "not-selected")), - )), - ); - assert!(sources(&directory).is_empty()); - let selected = browser.screen().selected_video().unwrap(); - assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7); - browser.save(CollectionKind::Saved); - browser.collection(CollectionKind::Saved); - browser.open(); - browser.pending = None; - browser.accept( - browser.generation, - Ok(Response::Source( - selected.clone(), - Box::new(source(&first, "wrong-identity")), - )), - ); - assert!(sources(&directory).is_empty()); - browser.open(); - browser.pending = None; - let mut invalid = source(&selected, "private-token"); - invalid - .headers - .insert("X-Invalid".into(), "private-header\nvalue".into()); - browser.accept( - browser.generation, - Ok(Response::Source(selected.clone(), Box::new(invalid))), - ); - assert!(sources(&directory).is_empty()); - assert!(!browser.status.contains("private-token")); - assert!(!browser.status.contains("private-header")); - for token in ["fresh-one", "fresh-two"] { - browser.open(); - assert!( - matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title) - ); - browser.pending = None; // Deliver the real source response without a network request. - browser.accept( - browser.generation, - Ok(Response::Source( - selected.clone(), - Box::new(source(&selected, token)), - )), - ); - } - let written = sources(&directory); - assert_eq!(written.len(), 2); - assert!( - written - .iter() - .all(|source| source["identity"]["episode"] == 7 - && source["identity"]["media_id"] == "first") - ); - assert!( - written - .iter() - .any(|source| source["url"].as_str().unwrap().ends_with("fresh-one")) - ); - assert!( - written - .iter() - .any(|source| source["url"].as_str().unwrap().ends_with("fresh-two")) - ); - assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress. - assert!( - !browser - .state - .contains(CollectionKind::Watching, &selected.id) - ); - assert!(browser.screen().error.is_some()); // Native launch errors remain visible. - } - - #[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))); - } -} +#[path = "../tests/unit/miruro_ui.rs"] +mod tests; diff --git a/src/thumbnail.rs b/src/thumbnail.rs index a26b104..2042545 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -313,339 +313,5 @@ fn left_aligned(area: Rect, size: Size) -> Rect { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() { - use ratatui::{Terminal, backend::TestBackend}; - use std::time::Duration; - let mut thumbnail = Thumbnail::new(); - thumbnail.selected = Some("fixture".into()); - let area = Rect::new(2, 2, 80, 8); - let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); - let mut loading_width = 0; - terminal - .draw(|frame| loading_width = thumbnail.draw(frame, area)) - .unwrap(); - let (_decode_tx, decode_rx) = mpsc::channel(); - thumbnail.decoder = Some(decode_rx); - let mut decoding_width = 0; - terminal - .draw(|frame| decoding_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, decoding_width); - thumbnail.decoder = None; - let (requests_tx, requests_rx) = mpsc::channel::(); - let (replies_tx, replies_rx) = mpsc::channel(); - thumbnail.image = Some(ThumbnailImage { - id: "fixture".into(), - protocol: ThreadProtocol::new( - requests_tx, - Some( - thumbnail - .picker - .new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)), - ), - ), - replies: replies_rx, - complete: false, - }); - let mut preparing_width = 0; - terminal - .draw(|frame| preparing_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, preparing_width); - terminal - .draw(|frame| preparing_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, preparing_width); - replies_tx - .send(Ok(requests_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .resize_encode() - .unwrap())) - .unwrap(); - let mut ready_width = 0; - terminal - .draw(|frame| ready_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!( - loading_width, ready_width, - "right-hand content must not move when the image finishes loading" - ); - thumbnail.cancel(); - thumbnail.select("fixture"); - terminal - .draw(|frame| ready_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, ready_width); - replies_tx - .send(Err("fixture renderer failure".into())) - .unwrap(); - let mut error_width = 0; - terminal - .draw(|frame| error_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, error_width); - assert!(thumbnail.error.is_some()); - } - - #[test] - fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() { - use ratatui::{Terminal, backend::TestBackend}; - use std::time::Duration; - - let mut thumbnail = Thumbnail::new(); - let url = "https://example.test/cover.png"; - thumbnail.selected = Some(url.into()); - thumbnail.aspect = (2, 3); - let area = Rect::new(2, 2, 80, 8); - let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3)); - let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); - let mut loading_width = 0; - terminal - .draw(|frame| loading_width = thumbnail.draw(frame, area)) - .unwrap(); - assert_eq!(loading_width, slot.width); - // The artwork need not exactly match the placeholder's 2:3 aspect. - let protocol = thumbnail - .picker - .new_resize_protocol(image::DynamicImage::new_rgb8(60, 100)); - let (requests_tx, requests_rx) = mpsc::channel::(); - let (replies_tx, replies_rx) = mpsc::channel(); - thumbnail.image = Some(ThumbnailImage { - id: url.into(), - protocol: ThreadProtocol::new(requests_tx, Some(protocol)), - replies: replies_rx, - complete: false, - }); - terminal - .draw(|frame| { - assert_eq!(thumbnail.draw(frame, area), loading_width); - }) - .unwrap(); - let response = requests_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .resize_encode() - .unwrap(); - replies_tx.send(Ok(response)).unwrap(); - terminal - .draw(|frame| { - assert_eq!(thumbnail.draw(frame, area), loading_width); - }) - .unwrap(); - assert!(!thumbnail.is_loading()); - let size = thumbnail - .image - .as_ref() - .unwrap() - .protocol - .size_for(Resize::Scale(None), slot) - .unwrap(); - assert!(size.width <= slot.width && size.height <= slot.height); - let font = thumbnail.picker.font_size(); - let width = u32::from(size.width) * u32::from(font.width); - let height = u32::from(size.height) * u32::from(font.height); - assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height)); - thumbnail.cancel(); - thumbnail.select_url(url); - assert!(thumbnail.fetch.is_none()); - assert_eq!(thumbnail.selected.as_deref(), Some(url)); - terminal - .draw(|frame| { - assert_eq!(thumbnail.draw(frame, area), loading_width); - }) - .unwrap(); - thumbnail.clear_cache(); - assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty()); - } - - #[test] - fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() { - assert!(decode(b"not a JPEG".to_vec()).is_err()); - assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err()); - for format in [ - image::ImageFormat::Jpeg, - image::ImageFormat::Png, - image::ImageFormat::WebP, - ] { - let mut bytes = Cursor::new(Vec::new()); - image::DynamicImage::new_rgb8(2, 3) - .write_to(&mut bytes, format) - .unwrap(); - let image = decode(bytes.into_inner()).unwrap(); - assert_eq!((image.width(), image.height()), (2, 3)); - } - } - - #[test] - fn cancellation_discards_queued_decode_completion() { - let mut thumbnail = Thumbnail::new(); - let (tx, rx) = mpsc::channel(); - let (requests_tx, _requests_rx) = mpsc::channel(); - let (_replies_tx, replies_rx) = mpsc::channel(); - let protocol = thumbnail - .picker - .new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)); - thumbnail.selected = Some("old-video".into()); - thumbnail.decoder = Some(rx); - assert!( - tx.send(Ok(ThumbnailImage { - id: "old-video".into(), - protocol: ThreadProtocol::new(requests_tx, Some(protocol)), - replies: replies_rx, - complete: false, - })) - .is_ok() - ); - thumbnail.cancel(); - thumbnail.selected = Some("new-video".into()); - thumbnail.poll(); - assert!(thumbnail.image.is_none()); - assert!(thumbnail.error.is_none()); - } - - #[test] - fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() { - let mut thumbnail = Thumbnail::new(); - let completed = |id: String| { - let (requests, _receiver) = mpsc::channel(); - let (_sender, replies) = mpsc::channel(); - ThumbnailImage { - id, - protocol: ThreadProtocol::new( - requests, - Some( - Picker::halfblocks() - .new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)), - ), - ), - replies, - complete: true, - } - }; - for index in 0..CACHE_SIZE { - thumbnail.image = Some(completed(format!("video{index:06}"))); - thumbnail.cancel(); - } - thumbnail.image = Some(completed("video000008".into())); - thumbnail.selected = Some("video000008".into()); - // This is the oldest cached image: storing the outgoing image first would evict it. - thumbnail.select("video000000"); - assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000"); - assert!(thumbnail.fetch.is_none()); - thumbnail.select("video000001"); - assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001"); - assert!(thumbnail.fetch.is_none()); - thumbnail.cancel(); - assert!( - !thumbnail - .cached - .iter() - .any(|image| image.id == "video000002") - ); - assert!( - thumbnail - .cached - .iter() - .any(|image| image.id == "video000000") - ); - assert!( - thumbnail - .cached - .iter() - .any(|image| image.id == "video000008") - ); - thumbnail.clear_cache(); - assert!(thumbnail.cached.is_empty()); - assert!(thumbnail.image.is_none()); - } - - #[test] - fn small_thumbnail_fills_popup_proportionally_and_resizes() { - use ratatui::{Terminal, backend::TestBackend, style::Color}; - use std::time::Duration; - - let mut thumbnail = Thumbnail::new(); - let font = thumbnail.picker.font_size(); - let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 48, - 36, - image::Rgb([240, 40, 70]), - )); - let (requests_tx, requests_rx) = mpsc::channel::(); - let (replies_tx, replies_rx) = mpsc::channel(); - thumbnail.selected = Some("thumbnail".into()); - thumbnail.image = Some(ThumbnailImage { - id: "thumbnail".into(), - protocol: ThreadProtocol::new( - requests_tx, - Some(thumbnail.picker.new_resize_protocol(image)), - ), - replies: replies_rx, - complete: false, - }); - let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); - for area in [ - Rect::new(3, 2, 80, 18), - Rect::new(7, 4, 30, 8), - Rect::new(3, 2, 9, 18), - Rect::new(7, 4, 30, 8), - ] { - let mut pending_width = 0; - terminal - .draw(|frame| { - pending_width = thumbnail.draw(frame, area); - }) - .unwrap(); - assert!(thumbnail.is_loading()); - assert!( - !terminal.backend().buffer().content.iter().any(|cell| { - matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) - }), - "a resize must not render stale pixels" - ); - let response = requests_rx - .recv_timeout(Duration::from_secs(2)) - .unwrap() - .resize_encode() - .unwrap(); - replies_tx.send(Ok(response)).unwrap(); - let mut occupied_width = 0; - terminal - .draw(|frame| { - occupied_width = thumbnail.draw(frame, area); - }) - .unwrap(); - assert!(!thumbnail.is_loading()); - assert_eq!(pending_width, occupied_width); - let buffer = terminal.backend().buffer(); - let pixels: Vec<_> = (0..40) - .flat_map(|y| (0..100).map(move |x| (x, y))) - .filter(|&(x, y)| { - let cell = &buffer[(x, y)]; - matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) - }) - .collect(); - let left = pixels.iter().map(|&(x, _)| x).min().unwrap(); - let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1; - let top = pixels.iter().map(|&(_, y)| y).min().unwrap(); - let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1; - assert!(left >= area.x && right <= area.right()); - assert_eq!(left, area.x); - assert_eq!(occupied_width, right - left); - assert!(top >= area.y && bottom <= area.bottom()); - assert!(right - left == area.width || bottom - top == area.height); - assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1); - let width = u32::from(right - left) * u32::from(font.width); - let height = u32::from(bottom - top) * u32::from(font.height); - assert!( - width.abs_diff(height * 4 / 3) - <= u32::from(font.width) + u32::from(font.height) * 4 / 3 - ); - } - } -} +#[path = "../tests/unit/thumbnail.rs"] +mod tests; diff --git a/src/video_state.rs b/src/video_state.rs index c60d742..cd4adff 100644 --- a/src/video_state.rs +++ b/src/video_state.rs @@ -1254,1439 +1254,5 @@ impl ProgressStream { } #[cfg(test)] -mod tests { - use super::*; - - struct TestDirectory(PathBuf); - impl TestDirectory { - fn new() -> Self { - loop { - let path = std::env::temp_dir().join(format!( - "tubular-watch-test-{}-{}", - std::process::id(), - NEXT_NAME.fetch_add(1, Ordering::Relaxed) - )); - match create_private_directory(&path) { - Ok(()) => return Self(path), - Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, - Err(e) => panic!("{e}"), - } - } - } - fn config(&self) -> PathBuf { - self.0.join("config.toml") - } - } - impl Drop for TestDirectory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - fn video() -> Video { - Video { - id: "jNQXAC9IVRw".into(), - title: "Me at the zoo".into(), - channel: Some("jawed".into()), - channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()), - uploader_id: Some("@jawed".into()), - timestamp: Some(1_114_300_000), - publication_date: Some("2005-04-23".into()), - availability: None, - } - } - - fn append(path: &Path, bytes: &[u8]) { - let mut options = OpenOptions::new(); - options.write(true).append(true).create(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - options.open(path).unwrap().write_all(bytes).unwrap(); - } - - fn record(id: &str, event: &str, eligible: bool, position: f64) -> String { - format!( - "{{\"version\":1,\"event\":\"{event}\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":{eligible}}}\n" - ) - } - - fn miruro_video(episode: u32) -> Video { - Video { - id: Identity::new("opaque_Show-1".into(), episode) - .unwrap() - .key(), - title: format!("Example show · Episode {episode}"), - channel: Some("Miruro".into()), - channel_id: None, - uploader_id: None, - timestamp: Some(1_114_300_000), - publication_date: Some("2005-04-23".into()), - availability: None, - } - } - - fn show() -> crate::miruro_browser::Media { - crate::miruro_browser::Media { - id: "opaque_Show-1".into(), - title: "Example show".into(), - metadata: serde_json::json!({ - "id": "opaque_Show-1", - "title": {"english": "Example show", "native": null}, - "episode_counts": {"sub": 12, "dub": null}, - "genres": ["Adventure", "Fantasy"], - }), - } - } - - #[test] - fn miruro_library_round_trips_overlapping_tags_and_keeps_episode_state_independent() { - let directory = TestDirectory::new(); - let path = directory.0.join("miruro-library.toml"); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.shows().is_empty()); - assert!(!path.exists()); - let episode = miruro_video(1); - for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { - state.save(kind, &episode).unwrap(); - } - state - .set_watch_status(&episode, WatchStatus::Watched) - .unwrap(); - let episode_files = ["saved-videos.toml", "watch-later.toml", "watch-state.toml"] - .map(|name| (name, fs::read(directory.0.join(name)).unwrap())); - let mut first = show(); - let mut second = show(); - second.id = "another_Show".into(); - second.title = "Another show".into(); - second.metadata = serde_json::Value::Null; - state - .save_show( - &first, - &[" Alpha ".into(), "Alpha".into(), "Beta".into()].into(), - ) - .unwrap(); - state - .save_show(&second, &["Beta".into(), "Gamma".into()].into()) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - let mut state = State::open(&directory.config()).unwrap(); - assert_eq!(state.shows().len(), 2); - assert_eq!(state.shows()[0].media.metadata, first.metadata); - assert_eq!( - state.shows()[0].tags, - ["Alpha".into(), "Beta".into()].into() - ); - assert_eq!( - state.shows()[1].tags, - ["Beta".into(), "Gamma".into()].into() - ); - assert!(state.shows()[1].media.metadata.is_null()); - first.title = "Updated show title".into(); - first.metadata["title"]["english"] = serde_json::json!("Updated show title"); - state.save_show(&first, &BTreeSet::new()).unwrap(); - state.save_show(&first, &BTreeSet::new()).unwrap(); - let mut state = State::open(&directory.config()).unwrap(); - assert_eq!(state.shows().len(), 2); - assert_eq!(state.shows()[0].media.title, first.title); - assert_eq!(state.shows()[0].media.metadata, first.metadata); - assert!(state.shows()[0].tags.is_empty()); - state.remove_show(&first.id).unwrap(); - state.remove_show(&first.id).unwrap(); - let state = State::open(&directory.config()).unwrap(); - assert_eq!(state.shows().len(), 1); - assert_eq!(state.shows()[0].media.id, second.id); - assert!(state.is_watched(&episode.id)); - for (name, bytes) in episode_files { - assert_eq!(fs::read(directory.0.join(name)).unwrap(), bytes); - } - // Duplicate hand-edited records behave like the existing episode collections. - let document = fs::read_to_string(&path).unwrap(); - let duplicate = document.split_once("[[shows]]").unwrap().1; - append(&path, format!("\n[[shows]]{duplicate}").as_bytes()); - let mut state = State::open(&directory.config()).unwrap(); - assert_eq!(state.shows().len(), 1); - state.save_show(&second, &BTreeSet::new()).unwrap(); - assert_eq!(State::open(&directory.config()).unwrap().shows().len(), 1); - state.remove_show(&second.id).unwrap(); - assert!(State::open(&directory.config()).unwrap().shows().is_empty()); - } - - #[test] - fn miruro_library_rejects_invalid_inputs_and_files_without_changing_saved_state() { - let directory = TestDirectory::new(); - let path = directory.0.join("miruro-library.toml"); - let mut state = State::open(&directory.config()).unwrap(); - let media = show(); - state - .save_show(&media, &["Original".into()].into()) - .unwrap(); - let original = fs::read(&path).unwrap(); - for (field, value) in [ - ("id", "https://unsafe.example/show".to_owned()), - ("title", " ".to_owned()), - ("title", "bad\nshow".to_owned()), - ("title", "s".repeat(MAX_LINE + 1)), - ("metadata", "m".repeat(MAX_SHOW_METADATA_BYTES)), - ] { - let mut invalid_media = media.clone(); - match field { - "id" => invalid_media.id = value, - "title" => invalid_media.title = value, - _ => invalid_media.metadata = serde_json::Value::String(value), - } - assert!(state.save_show(&invalid_media, &BTreeSet::new()).is_err()); - assert_eq!(state.shows()[0].media.title, media.title); - assert_eq!(state.shows()[0].media.metadata, media.metadata); - assert_eq!(fs::read(&path).unwrap(), original); - } - for tags in [ - [" ".into()].into(), - ["bad\ttag".into()].into(), - ["t".repeat(MAX_TAG_BYTES + 1)].into(), - (0..=MAX_SHOW_TAGS).map(|index| index.to_string()).collect(), - ] { - assert!(state.save_show(&media, &tags).is_err()); - assert_eq!(state.shows()[0].tags, ["Original".into()].into()); - assert_eq!(fs::read(&path).unwrap(), original); - } - assert!(state.remove_show("https://unsafe.example/show").is_err()); - assert_eq!(fs::read(&path).unwrap(), original); - let valid = std::str::from_utf8(&original).unwrap(); - let mut invalid_files = vec![ - "not = [valid".to_owned(), - "version = 2\nshows = []".to_owned(), - "version = 1\nunexpected = true".to_owned(), - "version = 1\nshows = [1]".to_owned(), - "version = 1\n[[shows]]\nid = 'stable'\ntitle = 'Title'\nmetadata = '{'\ntags = []" - .to_owned(), - ]; - let mut invalid_tags = Array::new(); - invalid_tags.push(1); - let mut blank_tag = Array::new(); - blank_tag.push(" "); - let mut excessive_tags = Array::new(); - for _ in 0..=MAX_SHOW_TAGS { - excessive_tags.push("Tag"); - } - for (field, item) in [ - ("tags", value(invalid_tags)), - ("tags", value(blank_tag)), - ("tags", value(excessive_tags)), - ("id", value("../unsafe")), - ("title", value("")), - ("title", value("s".repeat(MAX_LINE + 1))), - ( - "metadata", - value(format!("\"{}\"", "m".repeat(MAX_SHOW_METADATA_BYTES))), - ), - ("unknown", value(true)), - ] { - let mut document = valid.parse::().unwrap(); - document["shows"] - .as_array_of_tables_mut() - .unwrap() - .get_mut(0) - .unwrap()[field] = item; - invalid_files.push(document.to_string()); - } - for contents in invalid_files { - fs::write(&path, &contents).unwrap(); - assert!(State::open(&directory.config()).is_err(), "{contents}"); - assert_eq!(fs::read_to_string(&path).unwrap(), contents); - } - } - - #[test] - fn miruro_library_does_not_overwrite_external_creations_or_edits() { - let directory = TestDirectory::new(); - let path = directory.0.join("miruro-library.toml"); - let mut state = State::open(&directory.config()).unwrap(); - let media = show(); - fs::write(&path, "external creation").unwrap(); - assert!(state.save_show(&media, &BTreeSet::new()).is_err()); - assert!(state.shows().is_empty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "external creation"); - fs::remove_file(&path).unwrap(); - state - .save_show(&media, &["Original".into()].into()) - .unwrap(); - append(&path, b"\n# external edit\n"); - let changed = fs::read(&path).unwrap(); - let mut updated = media.clone(); - updated.title = "Replacement".into(); - assert!(state.save_show(&updated, &["New".into()].into()).is_err()); - assert!(state.remove_show(&media.id).is_err()); - assert!(state.remove_show("missing_Show").is_err()); - assert_eq!(state.shows().len(), 1); - assert_eq!(state.shows()[0].media.title, media.title); - assert_eq!(state.shows()[0].tags, ["Original".into()].into()); - assert_eq!(fs::read(&path).unwrap(), changed); - } - - fn miruro_source(video: &Video) -> SourceDocument { - SourceDocument { - version: 1, - url: "https://cdn.example/video.m3u8?token=private-token".into(), - headers: [("Referer".into(), "https://player.example/".into())].into(), - identity: Identity::from_key(&video.id).unwrap(), - title: Some(video.title.clone()), - subtitles: Vec::new(), - } - } - - fn miruro_record( - video: &Video, - event: &str, - eligible: bool, - position: f64, - duration: Option, - ) -> String { - serde_json::json!({ - "version": 2, - "event": event, - "media_identity": Identity::from_key(&video.id).unwrap(), - "position_seconds": position, - "duration_seconds": duration, - "completion_eligible": eligible, - }) - .to_string() - + "\n" - } - - #[test] - fn manual_status_transitions_preserve_both_providers_and_other_collections() { - for video in [video(), miruro_video(1)] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let other = miruro_video(2); - state - .set_watch_status(&other, WatchStatus::Watched) - .unwrap(); - for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { - state.save(kind, &video).unwrap(); - } - let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap(); - let later = fs::read(directory.0.join("watch-later.toml")).unwrap(); - for status in [ - WatchStatus::Watching, - WatchStatus::Watched, - WatchStatus::Watching, - ] { - state.set_watch_status(&video, status).unwrap(); - let watching = fs::read(directory.0.join("currently-watching.toml")).unwrap(); - let watched = - read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(); - state.set_watch_status(&video, status).unwrap(); - assert_eq!( - fs::read(directory.0.join("currently-watching.toml")).unwrap(), - watching - ); - assert_eq!( - read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(), - watched - ); - state = State::open(&directory.config()).unwrap(); - assert!(state.is_watched(&other.id)); - assert_eq!( - state.is_watched(&video.id), - matches!(status, WatchStatus::Watched) - ); - assert_eq!( - state.contains(CollectionKind::Watching, &video.id), - matches!(status, WatchStatus::Watching) - ); - if matches!(status, WatchStatus::Watching) { - assert_eq!( - serde_json::to_value(&state.videos(CollectionKind::Watching)[0]).unwrap(), - serde_json::to_value(&video).unwrap() - ); - } - assert_eq!( - fs::read(directory.0.join("saved-videos.toml")).unwrap(), - saved - ); - assert_eq!( - fs::read(directory.0.join("watch-later.toml")).unwrap(), - later - ); - } - } - } - - #[test] - fn manual_watching_ignores_old_completion_replays_but_accepts_new_sessions() { - for video in [video(), miruro_video(1)] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let completion = if video.id.starts_with("miruro:") { - miruro_record(&video, "ended", true, 95.0, Some(100.0)) - } else { - record(&video.id, "ended", true, 95.0) - }; - let first = state.progress_file(&video).unwrap(); - register_identity(&first, &video.id).unwrap(); - append(&first, completion.as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - let second = state.progress_file(&video).unwrap(); - register_identity(&second, &video.id).unwrap(); - // Neither the reports nor the second session have been read by this instance. - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - state - .set_watch_status(&video, WatchStatus::Watching) - .unwrap(); - for report in [&first, &second] { - append(report, completion.as_bytes()); - } - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - assert!(!state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Watching, &video.id)); - let new_session = state.progress_file(&video).unwrap(); - register_identity(&new_session, &video.id).unwrap(); - append(&new_session, completion.as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - assert!(!state.contains(CollectionKind::Watching, &video.id)); - } - } - - #[test] - fn manual_status_rejects_invalid_videos_and_external_edits_before_writing() { - for video in [video(), miruro_video(1)] { - for status in [WatchStatus::Watching, WatchStatus::Watched] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let mut invalid_video = video.clone(); - invalid_video.id = "--invalid".into(); - assert!(state.set_watch_status(&invalid_video, status).is_err()); - assert!(!directory.0.join("watch-state.toml").exists()); - assert!(!directory.0.join("currently-watching.toml").exists()); - state - .set_watch_status(&video, WatchStatus::Watching) - .unwrap(); - state - .set_watch_status(&video, WatchStatus::Watched) - .unwrap(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - for name in ["watch-state.toml", "currently-watching.toml"] { - let path = directory.0.join(name); - let original = fs::read(&path).unwrap(); - append(&path, b"\n# external edit\n"); - let changed = fs::read(&path).unwrap(); - assert!(state.set_watch_status(&video, status).is_err()); - assert_eq!(fs::read(&path).unwrap(), changed); - assert!(state.is_watched(&video.id)); - assert!(!state.contains(CollectionKind::Watching, &video.id)); - assert!(!report.with_file_name("watching-seen.toml").exists()); - fs::write(path, original).unwrap(); - } - } - } - } - - #[test] - fn manual_watching_clears_consumed_pending_completion_and_checks_session_edits() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); - let watched = directory.0.join("watch-state.toml"); - fs::write(&watched, "external edit").unwrap(); - assert!(state.poll_progress().is_err()); - assert!(state.pending.contains(&video.id)); - fs::remove_file(watched).unwrap(); - let marker = report.with_file_name("watching-seen.toml"); - fs::write(&marker, "external edit").unwrap(); - assert!( - state - .set_watch_status(&video, WatchStatus::Watching) - .is_err() - ); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); - fs::remove_file(&marker).unwrap(); - state - .set_watch_status(&video, WatchStatus::Watching) - .unwrap(); - assert!(!state.pending.contains(&video.id)); - state.poll_progress().unwrap(); - assert!(!state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Watching, &video.id)); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - assert!(!state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Watching, &video.id)); - } - - #[test] - fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { - state.save(kind, &video).unwrap(); - state.save(kind, &video).unwrap(); - assert_eq!(state.videos(kind).len(), 1); - assert!(state.contains(kind, &video.id)); - } - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - append( - &report, - record(&video.id, "progress", true, 95.0).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(!state.poll_progress().unwrap()); - let mut restarted = State::open(&directory.config()).unwrap(); - for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { - let stored = &restarted.videos(kind)[0]; - assert_eq!(stored.id, video.id); - assert_eq!(stored.title, video.title); - assert_eq!(stored.channel, video.channel); - assert_eq!(stored.channel_id, video.channel_id); - assert_eq!(stored.uploader_id, video.uploader_id); - assert_eq!(stored.timestamp, video.timestamp); - assert_eq!(stored.publication_date, video.publication_date); - } - assert!(restarted.is_watched(&video.id)); - restarted - .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) - .unwrap(); - restarted - .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) - .unwrap(); - let mut restarted = State::open(&directory.config()).unwrap(); - assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); - assert!(restarted.contains(CollectionKind::Saved, &video.id)); - assert!(restarted.is_watched(&video.id)); - restarted.save(CollectionKind::WatchLater, &video).unwrap(); - restarted - .remove(CollectionKind::Saved, std::slice::from_ref(&video.id)) - .unwrap(); - let mut restarted = State::open(&directory.config()).unwrap(); - assert!(restarted.videos(CollectionKind::Saved).is_empty()); - assert!(restarted.contains(CollectionKind::WatchLater, &video.id)); - assert!(restarted.is_watched(&video.id)); - assert!(!restarted.poll_progress().unwrap()); - } - - #[test] - fn malformed_and_externally_changed_state_is_never_overwritten() { - let directory = TestDirectory::new(); - let path = directory.0.join("watch-later.toml"); - let mut state = State::open(&directory.config()).unwrap(); - fs::write(&path, "not = [valid").unwrap(); - assert!(state.save(CollectionKind::WatchLater, &video()).is_err()); - assert!(state.videos(CollectionKind::WatchLater).is_empty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid"); - assert!(State::open(&directory.config()).is_err()); - fs::remove_file(&path).unwrap(); - let saved_path = directory.0.join("saved-videos.toml"); - fs::write(&saved_path, "not = [valid").unwrap(); - assert!(state.save(CollectionKind::Saved, &video()).is_err()); - assert!(state.videos(CollectionKind::Saved).is_empty()); - assert_eq!(fs::read_to_string(&saved_path).unwrap(), "not = [valid"); - assert!(State::open(&directory.config()).is_err()); - fs::remove_file(&saved_path).unwrap(); - fs::write(&saved_path, "version = 1\n[[videos]]\nid = '--unsafe'\n").unwrap(); - assert!(State::open(&directory.config()).is_err()); - assert_eq!( - fs::read_to_string(&saved_path).unwrap(), - "version = 1\n[[videos]]\nid = '--unsafe'\n" - ); - fs::remove_file(&saved_path).unwrap(); - fs::write( - directory.0.join("watch-state.toml"), - "version = 1\nwatched = [\"--unsafe\"]\n", - ) - .unwrap(); - assert!(State::open(&directory.config()).is_err()); - assert_eq!( - fs::read_to_string(directory.0.join("watch-state.toml")).unwrap(), - "version = 1\nwatched = [\"--unsafe\"]\n" - ); - } - - #[test] - fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let report = state.progress_file(&video()).unwrap(); - register_identity(&report, &video().id).unwrap(); - append( - &report, - record(&video().id, "progress", true, 95.0).as_bytes(), - ); - let path = directory.0.join("watch-state.toml"); - fs::write(&path, "malformed state").unwrap(); - assert!(state.poll_progress().is_err()); - assert!(!state.is_watched(&video().id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "malformed state"); - fs::remove_file(&path).unwrap(); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video().id)); - assert!(!state.poll_progress().unwrap()); - } - - #[test] - fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let report = state.progress_file(&video()).unwrap(); - register_identity(&report, &video().id).unwrap(); - let line = record(&video().id, "ended", true, 95.0); - append(&report, &line.as_bytes()[..line.len() - 1]); - assert!(!state.poll_progress().unwrap()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - let mut other = video(); - other.id = "rQ_J9WH6CGk".into(); - let bad = state.progress_file(&other).unwrap(); - register_identity(&bad, &other.id).unwrap(); - append(&bad, b"not json\n"); - append(&report, b"\n"); - assert!(state.poll_progress().is_err()); - assert!(state.is_watched(&video().id)); - assert!(state.poll_progress().unwrap()); - assert!(!state.poll_progress().unwrap()); - } - - #[test] - fn wrong_identity_ineligible_positions_and_untrusted_reports_do_not_mark_watched() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let report = state.progress_file(&video()).unwrap(); - register_identity(&report, &video().id).unwrap(); - for line in [ - record("rQ_J9WH6CGk", "progress", true, 100.0), - record(&video().id, "progress", false, 100.0), - record(&video().id, "closed", true, 94.9), - ] { - append(&report, line.as_bytes()); - } - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video().id)); - assert!(!state.is_watched(&video().id)); - assert!(!state.is_watched("rQ_J9WH6CGk")); - for line in [ - record(&video().id, "unknown", true, 100.0), - record(&video().id, "progress", true, -1.0), - record(&video().id, "progress", true, 100.0).replace("\"version\":1", "\"version\":2"), - record(&video().id, "progress", true, 100.0) - .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), - ] { - append(&report, line.as_bytes()); - assert!(state.poll_progress().is_err()); - assert!(!state.is_watched(&video().id)); - assert!(!state.poll_progress().unwrap()); - } - #[cfg(unix)] - { - let symlink = state.progress_file(&video()).unwrap(); - register_identity(&symlink, &video().id).unwrap(); - let external = directory.0.join("external.jsonl"); - append( - &external, - record(&video().id, "progress", true, 100.0).as_bytes(), - ); - std::os::unix::fs::symlink(&external, &symlink).unwrap(); - assert!(state.poll_progress().is_err()); - assert!(!state.is_watched(&video().id)); - assert!(!state.poll_progress().unwrap()); - } - } - - #[test] - fn watching_requires_a_valid_positive_bound_acknowledgement() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.poll_progress().unwrap()); - register_identity(&report, &video.id).unwrap(); - assert!(!state.poll_progress().unwrap()); - for line in [ - record("rQ_J9WH6CGk", "progress", true, 100.0), - record(&video.id, "progress", true, 0.0), - ] { - append(&report, line.as_bytes()); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - for line in [ - record(&video.id, "unknown", true, 10.0), - record(&video.id, "progress", true, -1.0), - record(&video.id, "progress", true, 10.0) - .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), - record(&video.id, "progress", true, 10.0).replace("\"version\":1", "\"version\":2"), - ] { - append(&report, line.as_bytes()); - assert!(state.poll_progress().is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - } - append( - &report, - record(&video.id, "progress", false, 10.0).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.is_watched(&video.id)); - } - - #[test] - fn starts_arriving_while_closed_preserve_metadata_without_a_creator() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let mut video = video(); - video.channel = None; - video.channel_id = None; - video.uploader_id = None; - assert!(state.save(CollectionKind::Saved, &video).is_err()); - assert!(state.save(CollectionKind::WatchLater, &video).is_err()); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - drop(state); - append( - &report, - record(&video.id, "closed", true, 10.0) - .replace("\"duration_seconds\":100", "\"duration_seconds\":null") - .as_bytes(), - ); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.poll_progress().unwrap()); - let stored = &state.videos(CollectionKind::Watching)[0]; - assert_eq!(stored.id, video.id); - assert_eq!(stored.title, video.title); - assert_eq!(stored.channel, None); - assert_eq!(stored.channel_id, None); - assert_eq!(stored.uploader_id, None); - assert_eq!(stored.publication_date, video.publication_date); - assert_eq!(stored.timestamp, video.timestamp); - append(&report, record(&video.id, "ended", false, 100.0).as_bytes()); - assert!(!state.poll_progress().unwrap()); - assert!(!state.is_watched(&video.id)); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.poll_progress().unwrap()); - } - - #[test] - fn completion_wins_over_parallel_sessions_late_reports_and_replays() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { - state.save(kind, &video).unwrap(); - } - let first = state.progress_file(&video).unwrap(); - register_identity(&first, &video.id).unwrap(); - append(&first, record(&video.id, "progress", true, 94.9).as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.is_watched(&video.id)); - let second = state.progress_file(&video).unwrap(); - register_identity(&second, &video.id).unwrap(); - append( - &second, - record(&video.id, "progress", true, 20.0).as_bytes(), - ); - append(&first, record(&video.id, "ended", true, 95.0).as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - assert!(!state.contains(CollectionKind::Watching, &video.id)); - for report in [&first, &second] { - append(report, record(&video.id, "progress", true, 30.0).as_bytes()); - } - let replay = state.progress_file(&video).unwrap(); - register_identity(&replay, &video.id).unwrap(); - append( - &replay, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - state - .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) - .unwrap(); - assert!(state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Saved, &video.id)); - assert!(state.contains(CollectionKind::WatchLater, &video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - - #[test] - fn manual_removal_suppresses_existing_sessions_across_restart_but_not_new_sessions() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { - state.save(kind, &video).unwrap(); - } - let first = state.progress_file(&video).unwrap(); - register_identity(&first, &video.id).unwrap(); - append(&first, record(&video.id, "progress", true, 10.0).as_bytes()); - assert!(state.poll_progress().unwrap()); - // Simulate a crash after collection persistence but before its admission marker. - fs::remove_file(first.with_file_name("watching-seen.toml")).unwrap(); - let second = state.progress_file(&video).unwrap(); - register_identity(&second, &video.id).unwrap(); - append( - &second, - record(&video.id, "progress", true, 20.0).as_bytes(), - ); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - state - .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) - .unwrap(); - for report in [&first, &second] { - append(report, record(&video.id, "progress", true, 50.0).as_bytes()); - } - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Saved, &video.id)); - assert!(state.contains(CollectionKind::WatchLater, &video.id)); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - let replay = state.progress_file(&video).unwrap(); - register_identity(&replay, &video.id).unwrap(); - append( - &replay, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.is_watched(&video.id)); - } - - #[test] - fn watching_write_conflicts_retry_consumed_starts() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - let path = directory.0.join("currently-watching.toml"); - fs::write(&path, "external edit").unwrap(); - append( - &report, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert_eq!(fs::read_to_string(&path).unwrap(), "external edit"); - assert!(state.poll_progress().is_err()); - fs::remove_file(&path).unwrap(); - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - let original = fs::read(&path).unwrap(); - append(&path, b"\n# external removal conflict\n"); - let replay = state.progress_file(&video).unwrap(); - register_identity(&replay, &video.id).unwrap(); - append( - &replay, - record(&video.id, "progress", true, 20.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(!replay.with_file_name("watching-seen.toml").exists()); - fs::write(&path, original).unwrap(); - assert!(!state.poll_progress().unwrap()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.poll_progress().unwrap()); - } - - #[test] - fn failed_session_admission_retries_without_losing_starts_or_allowing_readdition() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - state.streams[0].prepare().unwrap(); - let marker = report.with_file_name("watching-seen.toml"); - append(&marker, b"external edit"); - append( - &report, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!( - state - .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) - .is_err() - ); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); - fs::remove_file(&marker).unwrap(); - assert!(state.poll_progress().unwrap()); - state - .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) - .unwrap(); - append( - &report, - record(&video.id, "progress", true, 50.0).as_bytes(), - ); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - - #[test] - fn failed_completion_cleanup_retries_and_restart_prunes_stale_watching() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - append( - &report, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - let path = directory.0.join("currently-watching.toml"); - let original = fs::read(&path).unwrap(); - append(&path, b"\n# external edit\n"); - let changed = fs::read(&path).unwrap(); - append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); - assert!(state.poll_progress().is_err()); - assert!(state.is_watched(&video.id)); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert_eq!(fs::read(&path).unwrap(), changed); - append( - &report, - record(&video.id, "progress", true, 20.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - fs::write(&path, original).unwrap(); - assert!(state.poll_progress().unwrap()); - assert!(!state.contains(CollectionKind::Watching, &video.id)); - drop(state); - // Crash recovery must also handle a watched write committed before collection cleanup. - fs::write(&path, video_document(std::iter::once(&video)).to_string()).unwrap(); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.poll_progress().unwrap()); - } - - #[test] - fn legacy_sessions_use_saved_metadata_when_available_and_still_mark_completion_without_it() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - state.save(CollectionKind::Saved, &video).unwrap(); - let report = state.progress_file(&video).unwrap(); - fs::remove_file(report.with_file_name("video.toml")).unwrap(); - register_identity(&report, &video.id).unwrap(); - append( - &report, - record(&video.id, "progress", true, 10.0).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(state.contains(CollectionKind::Watching, &video.id)); - append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - let mut other = video.clone(); - other.id = "rQ_J9WH6CGk".into(); - let report = state.progress_file(&other).unwrap(); - fs::remove_file(report.with_file_name("video.toml")).unwrap(); - register_identity(&report, &other.id).unwrap(); - append( - &report, - record(&other.id, "progress", true, 10.0).as_bytes(), - ); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - append(&report, record(&other.id, "closed", true, 95.0).as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&other.id)); - } - - #[test] - fn session_metadata_and_admission_markers_must_match_the_bound_identity() { - for mismatched_snapshot in [true, false] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - if mismatched_snapshot { - let mut other = video.clone(); - other.id = "rQ_J9WH6CGk".into(); - fs::write( - report.with_file_name("video.toml"), - video_document(std::iter::once(&other)).to_string(), - ) - .unwrap(); - } else { - append( - &report.with_file_name("watching-seen.toml"), - b"version = 1\nid = \"rQ_J9WH6CGk\"\n", - ); - } - append(&report, record(&video.id, "ended", true, 100.0).as_bytes()); - assert!(state.poll_progress().is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - } - } - - #[cfg(unix)] - #[test] - fn session_snapshots_reject_symlinks_and_shared_permissions() { - use std::os::unix::fs::PermissionsExt; - for symlink in [true, false] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - let snapshot = report.with_file_name("video.toml"); - if symlink { - let external = directory.0.join("external.toml"); - fs::write(&external, fs::read(&snapshot).unwrap()).unwrap(); - fs::remove_file(&snapshot).unwrap(); - std::os::unix::fs::symlink(&external, &snapshot).unwrap(); - } else { - fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o644)).unwrap(); - } - append( - &report, - record(&video.id, "progress", true, 100.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - } - } - - #[test] - fn watching_and_session_snapshots_keep_date_handle_channel_and_schema_validation() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - for invalid_field in ["date", "handle", "channel"] { - let mut video = video(); - match invalid_field { - "date" => video.publication_date = Some("2005-02-30".into()), - "handle" => video.uploader_id = Some("not-a-handle".into()), - "channel" => video.channel_id = Some("not-a-channel-id".into()), - _ => unreachable!(), - } - assert!(state.progress_file(&video).is_err()); - assert!(state.save(CollectionKind::Watching, &video).is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - let path = directory.0.join("currently-watching.toml"); - let contents = "version = 1\nvideos = []\nunexpected = true\n"; - fs::write(&path, contents).unwrap(); - assert!(State::open(&directory.config()).is_err()); - assert_eq!(fs::read_to_string(&path).unwrap(), contents); - } - - #[test] - fn watching_and_session_snapshots_reject_oversized_files() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - OpenOptions::new() - .write(true) - .open(report.with_file_name("video.toml")) - .unwrap() - .set_len(MAX_STATE_BYTES + 1) - .unwrap(); - append( - &report, - record(&video.id, "progress", true, 100.0).as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - File::create(directory.0.join("currently-watching.toml")) - .unwrap() - .set_len(MAX_STATE_BYTES + 1) - .unwrap(); - assert!(State::open(&directory.config()).is_err()); - } - - #[test] - fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() { - let directory = TestDirectory::new(); - let video = miruro_video(1); - let mut state = State::open(&directory.config()).unwrap(); - for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { - state.save(kind, &video).unwrap(); - } - let source = miruro_source(&video); - let (source_file, report) = state.miruro_session(&video, &source).unwrap(); - let source_bytes = fs::read(&source_file).unwrap(); - assert_eq!(source_bytes, serde_json::to_vec(&source).unwrap()); - assert!(!report.exists()); - assert_eq!( - fs::read_to_string(report.with_file_name("video-id")).unwrap(), - video.id - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for path in [ - &source_file, - &report.with_file_name("video-id"), - &report.with_file_name("video.toml"), - ] { - assert_eq!( - fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - for path in [ - report.parent().unwrap(), - &directory.0.join("playback-sessions"), - ] { - assert_eq!( - fs::metadata(path).unwrap().permissions().mode() & 0o777, - 0o700 - ); - } - } - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - drop(state); - // A GUI can acknowledge its final snapshot after Tubular has quit. - append( - &report, - miruro_record(&video, "closed", false, 10.0, None).as_bytes(), - ); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.poll_progress().unwrap()); - let stored = &state.videos(CollectionKind::Watching)[0]; - assert_eq!(stored.id, video.id); - assert_eq!(stored.title, video.title); - assert_eq!(stored.channel, video.channel); - assert_eq!(stored.timestamp, video.timestamp); - assert_eq!(stored.publication_date, video.publication_date); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.contains(CollectionKind::Watching, &video.id)); - assert!(!state.poll_progress().unwrap()); - append( - &report, - miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.is_watched(&video.id)); - assert!(!state.poll_progress().unwrap()); - for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { - let stored = &state.videos(kind)[0]; - assert_eq!(stored.id, video.id); - assert_eq!(stored.title, video.title); - assert_eq!(stored.channel, video.channel); - assert_eq!(stored.channel_id, None); - assert_eq!(stored.uploader_id, None); - assert_eq!(stored.timestamp, video.timestamp); - assert_eq!(stored.publication_date, video.publication_date); - } - for path in [ - directory.0.join("watch-later.toml"), - directory.0.join("saved-videos.toml"), - directory.0.join("currently-watching.toml"), - directory.0.join("watch-state.toml"), - report.with_file_name("video.toml"), - ] { - let contents = fs::read_to_string(path).unwrap(); - assert!(!contents.contains("private-token")); - assert!(!contents.contains("cdn.example")); - } - assert_eq!(fs::read(source_file).unwrap(), source_bytes); - assert!(report.exists()); - } - - #[test] - fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() { - for event in ["progress", "ended", "closed"] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = miruro_video(1); - let (_, report) = state - .miruro_session(&video, &miruro_source(&video)) - .unwrap(); - append( - &report, - miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(), - ); - assert!(!state.poll_progress().unwrap()); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&miruro_video(2).id)); - for (eligible, position, duration) in [ - (true, 0.0, Some(100.0)), - (true, 94.9, Some(100.0)), - (false, 100.0, Some(100.0)), - (true, 100.0, None), - ] { - append( - &report, - miruro_record(&video, event, eligible, position, duration).as_bytes(), - ); - state.poll_progress().unwrap(); - assert!(!state.is_watched(&video.id)); - } - assert!(state.contains(CollectionKind::Watching, &video.id)); - let completion = miruro_record(&video, event, true, 95.0, Some(100.0)); - append(&report, completion.as_bytes()); - append(&report, completion.as_bytes()); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.poll_progress().unwrap()); - } - } - - #[test] - fn miruro_v2_rejects_invalid_wire_schemas_numbers_and_namespaces() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = miruro_video(1); - let (_, report) = state - .miruro_session(&video, &miruro_source(&video)) - .unwrap(); - let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0)); - for line in [ - valid.replace("\"version\":2", "\"version\":1"), - valid.replace("\"version\":2", "\"version\":3"), - valid.replace("\"provider\":\"miruro\"", "\"provider\":\"youtube\""), - valid.replace("\"episode\":1", "\"episode\":0"), - valid.replace("\"episode\":1", "\"episode\":2147483648"), - valid.replace("\"position_seconds\":100.0", "\"position_seconds\":-1"), - valid.replace("\"position_seconds\":100.0", "\"position_seconds\":1e999"), - valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":0"), - valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":-1"), - valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":1e999"), - valid.replace("\"event\":\"progress\"", "\"event\":\"other\""), - valid.replacen('{', "{\"media_url\":null,", 1), - record(&self::video().id, "ended", true, 100.0) - .replace("\"version\":1", "\"version\":2"), - ] { - append(&report, line.as_bytes()); - assert!(state.poll_progress().is_err(), "{line}"); - assert!(state.videos(CollectionKind::Watching).is_empty()); - assert!(!state.is_watched(&video.id)); - assert!(!state.poll_progress().unwrap()); - } - } - - #[test] - fn miruro_partial_reports_and_bad_siblings_recover_after_restart() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = miruro_video(1); - let (source, report) = state - .miruro_session(&video, &miruro_source(&video)) - .unwrap(); - let completion = miruro_record(&video, "closed", true, 95.0, Some(100.0)); - append(&report, &completion.as_bytes()[..completion.len() - 1]); - assert!(!state.poll_progress().unwrap()); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(!state.poll_progress().unwrap()); - let other = miruro_video(2); - let (_, bad) = state - .miruro_session(&other, &miruro_source(&other)) - .unwrap(); - append(&bad, b"not json\n"); - append(&report, b"\n"); - assert!(state.poll_progress().is_err()); - assert!(state.is_watched(&video.id)); - assert!(!state.is_watched(&other.id)); - assert!(state.poll_progress().unwrap()); - assert!(!state.poll_progress().unwrap()); - drop(state); - assert!(source.exists()); - assert!(report.exists()); - let state = State::open(&directory.config()).unwrap(); - assert!(state.is_watched(&video.id)); - } - - #[test] - fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - for field in [ - "series", - "noncanonical", - "empty-title", - "control-title", - "channel", - "channel-id", - "uploader", - ] { - let mut video = miruro_video(1); - match field { - "series" => video.id = "miruro-series:opaque_Show-1".into(), - "noncanonical" => video.id = "miruro:opaque_Show-1:01".into(), - "empty-title" => video.title = " ".into(), - "control-title" => video.title.push('\n'), - "channel" => video.channel = Some("YouTube".into()), - "channel-id" => video.channel_id = self::video().channel_id, - "uploader" => video.uploader_id = Some("@jawed".into()), - _ => unreachable!(), - } - for kind in [ - CollectionKind::WatchLater, - CollectionKind::Saved, - CollectionKind::Watching, - ] { - assert!(state.save(kind, &video).is_err(), "{field}"); - } - assert!(state.progress_file(&video).is_err(), "{field}"); - } - for id in [ - "miruro-series:opaque_Show-1", - "miruro:opaque_Show-1:01", - "other:opaque_Show-1:1", - ] { - let mut watched = DocumentMut::new(); - watched["version"] = value(1); - let mut ids = Array::new(); - ids.push(id); - watched["watched"] = value(ids); - assert!(load_watched(&watched).is_err()); - } - } - - #[test] - fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let mut video = miruro_video(i32::MAX as u32); - video.id = Identity::new("a".repeat(512), i32::MAX as u32) - .unwrap() - .key(); - let mut source = miruro_source(&video); - source.identity.episode -= 1; - assert!(state.miruro_session(&video, &source).is_err()); - assert!(state.streams.is_empty()); - source.identity = Identity::from_key(&video.id).unwrap(); - source.title = Some("x".repeat(MAX_SOURCE_BYTES)); - assert!(state.miruro_session(&video, &source).is_err()); - assert!(state.streams.is_empty()); - source.title = None; - let (_, report) = state.miruro_session(&video, &source).unwrap(); - append( - &report, - miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), - ); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - drop(state); - let state = State::open(&directory.config()).unwrap(); - assert!(state.is_watched(&video.id)); - } - - #[test] - fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() { - for missing_snapshot in [true, false] { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = miruro_video(1); - let (_, report) = state - .miruro_session(&video, &miruro_source(&video)) - .unwrap(); - if missing_snapshot { - fs::remove_file(report.with_file_name("video.toml")).unwrap(); - } else { - append( - &report.with_file_name("video-id"), - &vec![b'x'; MAX_IDENTITY_BYTES as usize], - ); - } - append( - &report, - miruro_record(&video, "ended", true, 100.0, Some(100.0)).as_bytes(), - ); - drop(state); - let mut state = State::open(&directory.config()).unwrap(); - assert!(state.poll_progress().is_err()); - assert!(!state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - } - - #[test] - fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() { - let directory = TestDirectory::new(); - let mut state = State::open(&directory.config()).unwrap(); - let video = video(); - let report = state.progress_file(&video).unwrap(); - register_identity(&report, &video.id).unwrap(); - let valid = record(&video.id, "ended", true, 95.0); - for url in [ - format!("https://youtu.be/{}", video.id), - format!("https://www.youtube.com/watch?v={}&t=1", video.id), - format!("http://www.youtube.com/watch?v={}", video.id), - ] { - append( - &report, - valid - .replace( - &format!("https://www.youtube.com/watch?v={}", video.id), - &url, - ) - .as_bytes(), - ); - assert!(state.poll_progress().is_err()); - assert!(!state.is_watched(&video.id)); - assert!(state.videos(CollectionKind::Watching).is_empty()); - } - append( - &report, - valid - .replacen('{', "{\"legacy_extension\":true,", 1) - .as_bytes(), - ); - assert!(state.poll_progress().unwrap()); - assert!(state.is_watched(&video.id)); - } -} +#[path = "../tests/unit/video_state.rs"] +mod tests; diff --git a/src/youtube.rs b/src/youtube.rs index 43d5a37..99c2b30 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -998,1133 +998,5 @@ impl Drop for PlayerLaunch { } #[cfg(test)] -mod tests { - use super::*; - - const ONE_CHANNEL: [FeedRequest; 1] = [FeedRequest { - channel: 0, - start: 1, - count: PAGE_SIZE, - }]; - - fn full_progress(requests: &[FeedRequest]) -> Vec { - requests - .iter() - .map(|&request| FeedProgress { - request, - count: request.count, - }) - .collect() - } - - #[test] - fn feed_cursor_shares_60_rows_across_17_channels_with_continuous_uneven_offsets() { - let mut cursor = FeedCursor::default(); - assert!(!cursor.has_more()); - let mut offsets = [1; 17]; - for _ in 0..3 { - let requests = cursor.plan(17).unwrap(); - let planned = cursor.clone(); - assert_eq!( - cursor.plan(17).unwrap(), - requests, - "retrying must preserve the plan" - ); - assert_eq!( - cursor, planned, - "planning must not advance offsets or rotation" - ); - assert_eq!(requests.len(), 17); - assert_eq!( - requests.iter().map(|request| request.count).sum::(), - PAGE_SIZE - ); - assert_eq!( - requests.iter().filter(|request| request.count == 4).count(), - 9 - ); - assert_eq!( - requests.iter().filter(|request| request.count == 3).count(), - 8 - ); - assert!(requests.windows(2).all(|pair| { - (pair[0].start, pair[0].count, pair[0].channel) - < (pair[1].start, pair[1].count, pair[1].channel) - })); - for request in &requests { - assert_eq!(request.start, offsets[request.channel]); - offsets[request.channel] += request.count; - } - cursor.accept(&full_progress(&requests)).unwrap(); - } - // Rotating the nine remainder rows avoids permanently favoring the first channels. - assert!(offsets.iter().max().unwrap() - offsets.iter().min().unwrap() <= 1); - let saved = cursor.clone(); - assert!(cursor.plan(16).is_err()); - assert_eq!(cursor, saved); - } - - #[test] - fn exhausted_channel_quota_moves_to_survivors_without_restarting_offsets() { - let mut cursor = FeedCursor::default(); - let first = cursor.plan(3).unwrap(); - let mut progress = full_progress(&first); - progress - .iter_mut() - .find(|entry| entry.request.channel == 1) - .unwrap() - .count = 0; - cursor.accept(&progress).unwrap(); - let second = cursor.plan(3).unwrap(); - assert_eq!( - second, - [ - FeedRequest { - channel: 0, - start: 21, - count: 30 - }, - FeedRequest { - channel: 2, - start: 21, - count: 30 - }, - ] - ); - let mut progress = full_progress(&second); - progress[0].count = 29; - cursor.accept(&progress).unwrap(); - let last = cursor.plan(3).unwrap(); - assert_eq!( - last, - [FeedRequest { - channel: 2, - start: 51, - count: PAGE_SIZE - }] - ); - cursor - .accept(&[FeedProgress { - request: last[0], - count: 0, - }]) - .unwrap(); - assert!(!cursor.has_more()); - assert!(cursor.plan(3).unwrap().is_empty()); - let mut empty = FeedCursor::default(); - assert!(empty.plan(0).unwrap().is_empty()); - assert!(!empty.has_more()); - } - - #[test] - fn more_than_60_channels_rotate_fairly_and_unvisited_channels_do_not_exhaust() { - let mut cursor = FeedCursor::default(); - let mut seen = [false; 137]; - let mut offsets = vec![1; 137]; - for _ in 0..3 { - let requests = cursor.plan(137).unwrap(); - assert_eq!(requests.len(), PAGE_SIZE); - for request in &requests { - assert_eq!(request.count, 1); - assert_eq!(request.start, offsets[request.channel]); - seen[request.channel] = true; - offsets[request.channel] += 1; - } - cursor.accept(&full_progress(&requests)).unwrap(); - } - assert!(seen.iter().all(|seen| *seen)); - let mut cursor = FeedCursor::default(); - for expected_remaining in [77, 17, 0] { - let requests = cursor.plan(137).unwrap(); - assert_eq!( - requests.iter().map(|request| request.count).sum::(), - PAGE_SIZE - ); - let progress: Vec<_> = requests - .iter() - .map(|&request| FeedProgress { request, count: 0 }) - .collect(); - cursor.accept(&progress).unwrap(); - assert_eq!( - cursor - .positions - .as_ref() - .unwrap() - .iter() - .filter(|offset| offset.is_some()) - .count(), - expected_remaining, - ); - assert_eq!(cursor.has_more(), expected_remaining != 0); - } - assert!(cursor.plan(137).unwrap().is_empty()); - } - - #[test] - fn cursor_rejects_invalid_or_stale_progress_and_overflow_atomically() { - let mut cursor = FeedCursor::default(); - assert!(cursor.accept(&[]).is_err()); - let requests = cursor.plan(3).unwrap(); - let valid = full_progress(&requests); - let saved = cursor.clone(); - let mut bad = valid.clone(); - bad[2].count += 1; - assert!(cursor.accept(&bad).is_err()); - assert_eq!(cursor, saved); - bad = valid.clone(); - bad[2].request.channel = 99; - assert!(cursor.accept(&bad).is_err()); - assert_eq!(cursor, saved); - bad = valid.clone(); - bad[2].request = bad[0].request; - assert!(cursor.accept(&bad).is_err()); - assert_eq!(cursor, saved); - bad = valid.clone(); - bad[2].request.start += 1; - assert!(cursor.accept(&bad).is_err()); - assert_eq!(cursor, saved); - bad = valid.clone(); - bad.reverse(); - assert!(cursor.accept(&bad).is_err()); - assert_eq!(cursor, saved); - assert!(cursor.accept(&valid[..2]).is_err()); - assert_eq!(cursor, saved); - cursor.accept(&valid).unwrap(); - let accepted = cursor.clone(); - assert!(cursor.accept(&valid).is_err()); - assert_eq!(cursor, accepted); - - let mut overflow = FeedCursor { - positions: Some(vec![Some(1), Some(usize::MAX - 19), Some(1)]), - rotation: 0, - }; - let requests = overflow.plan(3).unwrap(); - let saved = overflow.clone(); - assert!(overflow.accept(&full_progress(&requests)).is_err()); - assert_eq!(overflow, saved); - } - - #[cfg(unix)] - mod subprocesses { - use super::*; - - fn controlled(script: &str) -> Command { - let mut command = isolated_command(OsStr::new("/bin/sh")); - command.args(["-c", &format!("kill -STOP $$; {script}")]); - command - } - - fn wait_stopped(extraction: &Extraction) { - wait_stopped_pid(extraction.child.as_ref().unwrap().id()); - } - - fn wait_stopped_pid(pid: u32) { - let pid = pid as i32; - let mut status = 0; - loop { - // SAFETY: wait only for this test's owned child, without reaping its exit. - let result = unsafe { libc::waitpid(pid, &mut status, libc::WUNTRACED) }; - if result == -1 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { - continue; - } - assert_eq!(result, pid); - assert!(libc::WIFSTOPPED(status)); - break; - } - } - - fn release(extraction: &mut Extraction) { - let child = extraction.child.as_mut().unwrap(); - // SAFETY: this child is stopped at the deterministic test barrier. - assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGCONT) }, 0); - child.wait().unwrap(); - // Synchronize pipe draining too, then leave the real result for Job::poll. - // Blocking is confined to tests; production polling never waits for output. - let result = extraction.output.recv().unwrap(); - let (sender, receiver) = mpsc::channel(); - sender.send(result).unwrap(); - extraction.output = receiver; - } - - fn take_output(extraction: &mut Extraction) -> Receiver> { - let (_, receiver) = mpsc::channel(); - std::mem::replace(&mut extraction.output, receiver) - } - - fn assert_reaped(pid: u32) { - let mut status = 0; - // SAFETY: query only this test's child; ECHILD proves cancellation reaped it. - assert_eq!( - unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }, - -1 - ); - assert_eq!( - io::Error::last_os_error().raw_os_error(), - Some(libc::ECHILD) - ); - } - - #[test] - fn a_later_spawn_failure_cancels_and_reaps_already_started_batches() { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let directory = std::env::temp_dir().join(format!( - "tubular-extractor-spawn-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) - )); - std::fs::create_dir(&directory).unwrap(); - let ready = directory.join("ready"); - let mut first = isolated_command(OsStr::new("/bin/sh")); - first - .args(["-c", "printf '%s' $$ > \"$1\"; kill -STOP $$", "test"]) - .arg(&ready); - let mut started_pid = None; - let commands = std::iter::once(first).chain(std::iter::once_with(|| { - // The file only communicates identity; SIGSTOP is the completion barrier. - let pid = loop { - if let Ok(text) = std::fs::read_to_string(&ready) - && let Ok(pid) = text.parse::() - { - break pid; - } - thread::yield_now(); - }; - wait_stopped_pid(pid); - started_pid = Some(pid); - isolated_command(directory.join("missing-extractor").as_os_str()) - })); - let error = Job::spawn_all(commands).err().unwrap(); - assert_eq!(error.kind(), io::ErrorKind::NotFound); - assert_reaped(started_pid.unwrap()); - std::fs::remove_dir_all(directory).unwrap(); - } - - #[test] - fn queued_range_groups_keep_four_workers_and_preserve_parser_request_order() { - let requests: Vec<_> = (0..6) - .map(|channel| FeedRequest { - channel, - start: channel + 1, - count: channel + 1, - }) - .collect(); - let commands = requests.iter().map(|request| { - let output = - serde_json::json!({"entries": vec![serde_json::Value::Null; request.count]}); - controlled(&format!("printf '%s' '{output}'")) - }); - let mut job = Job::spawn_all(commands).unwrap(); - assert_eq!(job.children.len(), MAX_EXTRACTORS); - assert_eq!(job.queued.len(), 2); - for child in &job.children { - wait_stopped(child); - } - release(&mut job.children[3]); - assert!(job.poll().is_none()); - assert_eq!(job.queued.len(), 1); - assert_eq!( - job.children - .iter() - .filter(|child| child.completed.is_none()) - .count(), - MAX_EXTRACTORS - ); - wait_stopped(&job.children[4]); - release(&mut job.children[4]); - release(&mut job.children[1]); - assert!(job.poll().is_none()); - assert!(job.queued.is_empty()); - assert!( - job.children - .iter() - .filter(|child| child.completed.is_none()) - .count() - <= MAX_EXTRACTORS - ); - wait_stopped(&job.children[5]); - release(&mut job.children[5]); - assert!(job.poll().is_none()); - release(&mut job.children[2]); - assert!(job.poll().is_none()); - release(&mut job.children[0]); - let output = job.poll().unwrap().unwrap(); - let page = feed_results(&output, &requests).unwrap(); - assert_eq!(page.feed_progress, full_progress(&requests)); - assert!(page.videos.is_empty()); - assert!(job.poll().is_none()); - } - - #[test] - fn queued_spawn_failure_discards_outputs_and_cancels_running_and_queued_groups() { - let mut job = Job::spawn_all([ - controlled("printf '%s' 'completed output'"), - controlled("printf '%s' 'pending one'"), - controlled("printf '%s' 'pending two'"), - controlled("printf '%s' 'pending three'"), - isolated_command(OsStr::new("/dev/null/tubular-missing-extractor")), - controlled("printf '%s' 'must never start'"), - ]) - .unwrap(); - for child in &job.children { - wait_stopped(child); - } - let pids: Vec<_> = job.children[1..] - .iter() - .map(|child| child.child.as_ref().unwrap().id()) - .collect(); - let outputs: Vec<_> = job.children[1..].iter_mut().map(take_output).collect(); - release(&mut job.children[0]); - assert!( - job.poll() - .unwrap() - .unwrap_err() - .starts_with("Cannot start yt-dlp:") - ); - assert!(job.children.is_empty()); - assert!(job.queued.is_empty()); - for output in outputs { - assert!(output.recv().unwrap().is_ok()); - } - for pid in pids { - assert_reaped(pid); - } - assert!(job.poll().is_none()); - } - - #[test] - fn aggregate_waits_for_all_batches_and_merges_in_request_not_completion_order() { - let mut job = Job::spawn_all([ - controlled("printf '%s' '{\"batch\":1}'"), - controlled("printf '%s' '{\"batch\":2}'"), - controlled("printf '%s' '{\"batch\":3}'"), - ]) - .unwrap(); - for child in &job.children { - wait_stopped(child); - } - assert!(job.poll().is_none()); - release(&mut job.children[2]); - assert!( - job.poll().is_none(), - "the last batch cannot complete a page" - ); - release(&mut job.children[1]); - assert!(job.poll().is_none(), "the first batch is still outstanding"); - release(&mut job.children[0]); - let output = job.poll().unwrap().unwrap(); - let batches: Vec<_> = serde_json::Deserializer::from_str(&output) - .into_iter::() - .map(Result::unwrap) - .collect(); - assert_eq!( - batches, - [ - serde_json::json!({"batch":1}), - serde_json::json!({"batch":2}), - serde_json::json!({"batch":3}), - ] - ); - assert!(job.poll().is_none()); - } - - #[test] - fn a_failed_group_discards_completed_output_and_cancels_running_and_queued_siblings() { - let mut job = Job::spawn_all([ - controlled("printf '%s' '{\"complete\":true}'"), - controlled( - "printf '%s' '{\"partial\":true}'; printf '%s' 'batch failed' >&2; exit 7", - ), - controlled("/bin/sh -c 'kill -STOP $$' & wait"), - controlled("printf '%s' 'pending sibling'"), - controlled("printf '%s' 'queued sibling'"), - controlled("printf '%s' 'must never start'"), - ]) - .unwrap(); - for child in &job.children { - wait_stopped(child); - } - release(&mut job.children[0]); - assert!(job.poll().is_none()); - wait_stopped(&job.children[4]); - assert_eq!(job.queued.len(), 1); - let pids: Vec<_> = job.children[2..] - .iter() - .map(|child| child.child.as_ref().unwrap().id()) - .collect(); - let outputs: Vec<_> = job.children[2..].iter_mut().map(take_output).collect(); - release(&mut job.children[1]); - assert_eq!(job.poll().unwrap().unwrap_err(), "batch failed"); - assert!(job.children.is_empty()); - assert!(job.queued.is_empty()); - for output in outputs { - assert!(output.recv().unwrap().is_ok()); - } - for pid in pids { - assert_reaped(pid); - } - assert!(job.poll().is_none()); - } - - #[test] - fn unreadable_batch_output_fails_the_whole_page_and_cancels_siblings() { - let mut job = Job::spawn_all([ - controlled("printf '\\377'"), - controlled("printf '%s' 'must not finish'"), - ]) - .unwrap(); - for child in &job.children { - wait_stopped(child); - } - let pending_pid = job.children[1].child.as_ref().unwrap().id(); - let pending_output = take_output(&mut job.children[1]); - release(&mut job.children[0]); - assert!( - job.poll() - .unwrap() - .unwrap_err() - .starts_with("Cannot read yt-dlp output:") - ); - assert!(pending_output.recv().unwrap().is_ok()); - assert_reaped(pending_pid); - } - - #[test] - fn dropping_a_page_cancels_and_reaps_running_groups_and_drops_queued_commands() { - let mut job = Job::spawn_all([ - controlled("/bin/sh -c 'kill -STOP $$' & wait"), - controlled("printf '%s' 'must not finish'"), - controlled("printf '%s' 'pending two'"), - controlled("printf '%s' 'pending three'"), - controlled("printf '%s' 'queued one must never start'"), - controlled("printf '%s' 'queued two must never start'"), - ]) - .unwrap(); - assert_eq!(job.children.len(), MAX_EXTRACTORS); - assert_eq!(job.queued.len(), 2); - for child in &job.children { - wait_stopped(child); - } - let pids: Vec<_> = job - .children - .iter() - .map(|child| child.child.as_ref().unwrap().id()) - .collect(); - let outputs: Vec<_> = job.children.iter_mut().map(take_output).collect(); - drop(job); - for output in outputs { - assert!(output.recv().unwrap().is_ok()); - } - for pid in pids { - assert_reaped(pid); - } - } - - #[test] - fn dropping_after_the_leader_exits_still_cancels_inherited_pipe_owners() { - let mut job = - Job::spawn_all([controlled("/bin/sh -c 'kill -STOP $$' & exit 0")]).unwrap(); - wait_stopped(&job.children[0]); - let child = job.children[0].child.as_mut().unwrap(); - let pid = child.id(); - // SAFETY: resume this test's stopped leader, which exits without its descendant. - assert_eq!(unsafe { libc::kill(pid as i32, libc::SIGCONT) }, 0); - assert!(child.wait().unwrap().success()); - let output = take_output(&mut job.children[0]); - drop(job); - // EOF proves the descendant that inherited stdout/stderr was also canceled. - assert!(output.recv().unwrap().is_ok()); - assert_reaped(pid); - } - } - - #[test] - fn player_launch_rejects_unsafe_video_ids_before_spawning() { - for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { - assert!(matches!( - PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - } - - #[test] - fn browser_open_rejects_unsafe_video_ids_before_spawning() { - for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { - assert!(matches!( - open_in_browser(id), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - } - - #[test] - fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() { - for feed in [false, true] { - for (upload_date, timestamp, expected) in [ - (Some("20240229"), Some(0), Some("2024-02-29")), - (Some("20000229"), None, Some("2000-02-29")), - (Some("19000229"), None, None), - (Some("20230229"), Some(0), Some("~1970-01-01")), - (Some("20241301"), None, None), - (Some("日本語"), None, None), - (None, Some(-1), Some("~1969-12-31")), - (None, Some(1_709_251_199), Some("~2024-02-29")), - (None, Some(1_709_251_200), Some("~2024-03-01")), - (None, Some(i64::MAX), None), - (None, None, None), - ] { - let output = serde_json::json!({"entries":[{ - "id": "jNQXAC9IVRw", "title": "Video", - "upload_date": upload_date, "timestamp": timestamp, - }]}) - .to_string(); - let page = if feed { - feed_results(&output, &ONE_CHANNEL) - } else { - search_results(&output) - } - .unwrap(); - assert_eq!(page.videos[0].publication_date.as_deref(), expected); - } - } - } - - #[test] - fn thumbnails_bound_binary_input_and_reject_unsafe_ids() { - let bytes = [0xff, 0xd8, 0x00, 0xfe]; - assert_eq!(read_thumbnail(bytes.as_slice()).unwrap(), bytes); - assert_eq!( - read_thumbnail(io::repeat(0).take(MAX_THUMBNAIL_BYTES as u64)) - .unwrap() - .len(), - MAX_THUMBNAIL_BYTES - ); - assert!(read_thumbnail(io::repeat(0)).is_err()); - for id in ["../secrets", "abcdefghij/", "abcdefghijé", "--bad"] { - assert!(matches!( - ThumbnailJob::start(id), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - for url in [ - "http://example.test/cover.jpg", - "file:///tmp/cover.jpg", - "ftp://example.test/cover.jpg", - "//example.test/cover.jpg", - "https://", - "https://user:secret@example.test/cover.jpg", - "https://example.test/\ncover.jpg", - ] { - assert!(matches!( - ThumbnailJob::from_url(url), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - } - - #[test] - fn bad_video_entries_do_not_discard_valid_results() { - for feed in [false, true] { - for bad in [ - r#"{"id":"UC4QobU6STFB0P71PMvOGN5A","title":"Channel result"}"#, - r#"{"id":"--bad","title":"Unsafe ID"}"#, - r#"{"id":"jNQXAC9IVRw","title":"Bad channel","channel_id":"malformed"}"#, - r#"{"id":"jNQXAC9IVRw","title":"Bad handle","uploader_id":"@jawed\u001b"}"#, - r#"{"id":42,"title":"Wrong ID type"}"#, - r#"{"id":"jNQXAC9IVRw","title":null}"#, - r#"{"id":"jNQXAC9IVRw","title":"Bad date type","timestamp":"yesterday"}"#, - r#"{"id":"jNQXAC9IVRw","title":"Bad channel type","channel":[]}"#, - r#"{"id":"jNQXAC9IVRw"}"#, - "null", - "42", - "[]", - "{}", - ] { - let output = format!( - r#"{{"entries":[{{"id":"jNQXAC9IVRw","title":"Before"}},{bad},{{"id":"rQ_J9WH6CGk","title":"After"}}]}}"#, - ); - let page = if feed { - feed_results(&output, &ONE_CHANNEL) - } else { - search_results(&output) - } - .unwrap_or_else(|error| { - panic!("bad entry {bad} discarded the entire page: {error}") - }); - assert_eq!( - page.videos - .iter() - .map(|video| video.title.as_str()) - .collect::>(), - ["Before", "After"] - ); - } - } - } - - #[test] - fn members_only_rows_are_hidden_without_exhausting_search_or_feed_pages() { - let members = serde_json::json!({ - "id": "jNQXAC9IVRw", "title": "Members only", "availability": "subscriber_only" - }); - let mut entries = vec![members; PAGE_SIZE]; - let output = serde_json::json!({"entries": entries}).to_string(); - let search = search_results(&output).unwrap(); - assert!(search.videos.is_empty()); - assert!(search.has_more); - let mut cursor = FeedCursor::default(); - let requests = cursor.plan(1).unwrap(); - let feed = feed_results(&output, &requests).unwrap(); - assert!(feed.videos.is_empty()); - assert!(feed.has_more); - cursor.accept(&feed.feed_progress).unwrap(); - assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1); - - for (index, availability) in [ - Some("public"), - Some("unlisted"), - None, - Some("new_availability"), - ] - .into_iter() - .enumerate() - { - entries[index] = serde_json::json!({ - "id": format!("{index:011}"), - "title": "Join my channel membership", - "availability": availability, - }); - } - entries[4] = serde_json::json!({"id": "rQ_J9WH6CGk", "title": "No availability"}); - let output = serde_json::json!({"entries": entries}).to_string(); - for page in [ - search_results(&output).unwrap(), - feed_results(&output, &cursor.plan(1).unwrap()).unwrap(), - ] { - assert_eq!( - page.videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - [ - "00000000000", - "00000000001", - "00000000002", - "00000000003", - "rQ_J9WH6CGk" - ] - ); - assert!(page.has_more); - } - } - - #[test] - fn discarded_raw_entries_advance_the_cursor_only_after_acceptance() { - let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500}); - let mut entries = vec![bad; PAGE_SIZE]; - let output = serde_json::json!({"entries": entries}).to_string(); - let search = search_results(&output).unwrap(); - assert!(search.videos.is_empty()); - assert!(search.has_more); - assert!(search.feed_progress.is_empty()); - let mut cursor = FeedCursor::default(); - let requests = cursor.plan(1).unwrap(); - let saved = cursor.clone(); - let page = feed_results(&output, &requests).unwrap(); - assert!(page.videos.is_empty()); - assert!(page.has_more); - assert_eq!(page.feed_progress, full_progress(&requests)); - assert_eq!(cursor, saved, "parsing alone must not advance the cursor"); - cursor.accept(&page.feed_progress).unwrap(); - assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1); - - entries[0] = serde_json::json!({"id":"jNQXAC9IVRw","title":"Valid","timestamp":100}); - let page = feed_results( - &serde_json::json!({"entries": entries}).to_string(), - &cursor.plan(1).unwrap(), - ) - .unwrap(); - assert!(page.has_more); - assert_eq!(page.feed_progress[0].count, PAGE_SIZE); - assert_eq!(page.videos[0].id, "jNQXAC9IVRw"); - } - - #[test] - fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() { - let videos = search_results( - r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#, - ) - .unwrap() - .videos; - assert_eq!(videos[0].title, "Rust"); - assert_eq!(videos[0].channel, None); - assert_eq!(videos[0].channel_id, None); - assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos")); - for output in [ - "not JSON", - "{}", - r#"{"entries":null}"#, - r#"{"entries":{}}"#, - r#"{"entries":[{"id":} ]}"#, - ] { - assert!(search_results(output).is_err()); - } - } - - #[test] - fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() { - let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]} -{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#; - let requests = FeedCursor::default().plan(2).unwrap(); - let page = feed_results(output, &requests).unwrap(); - assert!(!page.has_more); - assert_eq!( - page.feed_progress - .iter() - .map(|entry| entry.count) - .collect::>(), - [3, 3] - ); - let videos = page.videos; - assert_eq!( - videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - ["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] - ); - assert_eq!(videos[1].title, "Newer"); - assert_eq!(videos[1].timestamp, Some(200)); - assert_eq!(videos[2].timestamp, None); - assert_eq!(videos[2].channel.as_deref(), Some("Rust")); - assert!( - videos - .iter() - .all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA")) - ); - assert!( - videos - .iter() - .all(|video| video.uploader_id.as_deref() == Some("@RustVideos")) - ); - let unavailable = feed_results( - r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#, - &ONE_CHANNEL, - ) - .unwrap() - .videos; - assert_eq!(unavailable[0].channel_id, None); - assert!(feed_results(r#"{"channel_id":"malformed","entries":[]}"#, &ONE_CHANNEL).is_err()); - assert!(feed_results("", &ONE_CHANNEL).is_err()); - assert!(feed_results("{}", &ONE_CHANNEL).is_err()); - assert!(feed_results("{not JSON}", &ONE_CHANNEL).is_err()); - let exhausted = feed_results(r#"{"entries":[]}"#, &ONE_CHANNEL).unwrap(); - assert!(exhausted.videos.is_empty()); - assert!(!exhausted.has_more); - assert_eq!(exhausted.feed_progress[0].count, 0); - } - - #[test] - fn channel_search_query_preserves_utf8_and_isolates_url_parameters() { - assert_eq!( - encode_query("café 🦀 &next=1#?/+%20\t\n\0 AZaz09-._~"), - "caf%C3%A9%20%F0%9F%A6%80%20%26next%3D1%23%3F%2F%2B%2520%09%0A%00%20AZaz09-._~" - ); - } - - #[test] - fn page_offsets_reject_zero_and_overflow_before_spawning() { - assert_eq!(page_end(1).unwrap(), PAGE_SIZE); - assert_eq!(page_end(PAGE_SIZE + 1).unwrap(), PAGE_SIZE * 2); - assert_eq!(page_end(usize::MAX - (PAGE_SIZE - 1)).unwrap(), usize::MAX); - let channels = vec!["https://www.youtube.com/@RustVideos".to_owned()]; - for start in [0, usize::MAX, usize::MAX - (PAGE_SIZE - 2)] { - assert!(matches!( - Job::search("rust", start), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - assert!(matches!( - Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }], ""), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - assert!(matches!( - Job::feed(&[], &ONE_CHANNEL, ""), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - - #[test] - fn feeds_reject_invalid_request_budgets_and_envelope_or_row_count_mismatches() { - let channels = vec!["https://www.youtube.com/@RustVideos".to_owned(); 2]; - for requests in [ - Vec::new(), - vec![FeedRequest { - count: 0, - ..ONE_CHANNEL[0] - }], - vec![FeedRequest { - count: PAGE_SIZE + 1, - ..ONE_CHANNEL[0] - }], - vec![FeedRequest { - channel: 2, - ..ONE_CHANNEL[0] - }], - vec![ - FeedRequest { - channel: 0, - start: 1, - count: 30, - }, - FeedRequest { - channel: 0, - start: 2, - count: 30, - }, - ], - vec![ - FeedRequest { - channel: 0, - start: 1, - count: 30, - }, - FeedRequest { - channel: 1, - start: 1, - count: 30, - }, - ], - vec![ - FeedRequest { - channel: 0, - start: 1, - count: 31, - }, - FeedRequest { - channel: 1, - start: 1, - count: 30, - }, - ], - ] { - assert!(matches!( - Job::feed(&channels, &requests, ""), - Err(error) if error.kind() == io::ErrorKind::InvalidInput - )); - } - let requests = FeedCursor::default().plan(2).unwrap(); - assert!(feed_results(r#"{"entries":[]}"#, &requests).is_err()); - for output in [ - r#"{"entries":[]} {"entries":[]}"#, - r#"{"entries":[]} garbage"#, - r#"{"entries":[null]} {"entries":null}"#, - r#"{"entries":null}"#, - r#"{"entries":{}}"#, - r#"{"entries":[{"id":}]}"#, - ] { - assert!(feed_results(output, &ONE_CHANNEL).is_err()); - } - let request = [FeedRequest { - channel: 0, - start: 1, - count: 2, - }]; - assert!(feed_results(r#"{"entries":[null,null,null]}"#, &request).is_err()); - assert!(feed_results(r#"{"entries":[]}"#, &[]).is_err()); - assert!( - feed_results( - r#"{"entries":[]}"#, - &[FeedRequest { - count: 0, - ..ONE_CHANNEL[0] - }] - ) - .is_err() - ); - assert!( - feed_results( - r#"{"entries":[]}"#, - &[FeedRequest { - count: PAGE_SIZE + 1, - ..ONE_CHANNEL[0] - }] - ) - .is_err() - ); - } - - #[test] - fn search_pagination_counts_raw_rows_and_preserves_relevance_order() { - for count in [0, PAGE_SIZE - 1, PAGE_SIZE, PAGE_SIZE + 1] { - let output = serde_json::json!({ - "entries": vec![serde_json::Value::Null; count] - }) - .to_string(); - let page = search_results(&output).unwrap(); - assert_eq!(page.has_more, count >= PAGE_SIZE); - assert!(page.videos.is_empty()); - assert!(page.feed_progress.is_empty()); - } - let mut entries = vec![serde_json::Value::Null; PAGE_SIZE]; - entries[0] = serde_json::json!({ - "id": "aaaaaaaaaaa", "title": "Relevant older video", "timestamp": 100 - }); - entries[PAGE_SIZE - 1] = serde_json::json!({ - "id": "bbbbbbbbbbb", "title": "Less relevant newer video", "timestamp": 300 - }); - let page = search_results(&serde_json::json!({"entries": entries}).to_string()).unwrap(); - assert!(page.has_more); - assert!(page.feed_progress.is_empty()); - assert_eq!(page.videos.len(), 2); - assert_eq!(page.videos[0].id, "aaaaaaaaaaa"); - assert_eq!(page.videos[1].id, "bbbbbbbbbbb"); - } - - #[test] - fn feed_pagination_counts_nulls_and_duplicates_before_filtering() { - for count in [0, PAGE_SIZE - 1, PAGE_SIZE] { - let output = serde_json::json!({ - "entries": vec![serde_json::Value::Null; count] - }) - .to_string(); - let page = feed_results(&output, &ONE_CHANNEL).unwrap(); - assert_eq!(page.has_more, count == PAGE_SIZE); - assert!(page.videos.is_empty()); - assert_eq!( - page.feed_progress, - [FeedProgress { - request: ONE_CHANNEL[0], - count - }] - ); - } - let entry = serde_json::json!({ - "id": "aaaaaaaaaaa", "title": "Repeated", "timestamp": 123 - }); - let page = feed_results( - &serde_json::json!({"entries": vec![entry; PAGE_SIZE]}).to_string(), - &ONE_CHANNEL, - ) - .unwrap(); - assert!(page.has_more); - assert_eq!(page.feed_progress[0].count, PAGE_SIZE); - assert_eq!(page.videos.len(), 1); - - let mut entries = vec![serde_json::Value::Null; PAGE_SIZE]; - entries[0] = serde_json::json!({"id": "bbbbbbbbbbb", "title": "Unknown"}); - let page = feed_results( - &serde_json::json!({"entries": entries}).to_string(), - &ONE_CHANNEL, - ) - .unwrap(); - assert!(page.has_more); - assert_eq!(page.feed_progress[0].count, PAGE_SIZE); - assert_eq!(page.videos.len(), 1); - assert_eq!(page.videos[0].timestamp, None); - } - - #[test] - fn feeds_retain_short_and_undated_rows_without_a_global_frontier() { - let requests = FeedCursor::default().plan(5).unwrap(); - let mut first = vec![serde_json::Value::Null; requests[0].count]; - first[0] = - serde_json::json!({"id":"aaaaaaaaaaa","title":"Older duplicate","timestamp":500}); - first[1] = - serde_json::json!({"id":"bbbbbbbbbbb","title":"First dated row","timestamp":300}); - first[2] = serde_json::json!({"id":"ccccccccccc","title":"Unknown"}); - let mut second = vec![serde_json::Value::Null; requests[1].count]; - second[0] = - serde_json::json!({"id":"aaaaaaaaaaa","title":"Newest duplicate","timestamp":600}); - second[1] = - serde_json::json!({"id":"ddddddddddd","title":"Second dated row","timestamp":400}); - let short = serde_json::json!({"entries":[ - {"id":"eeeeeeeeeee","title":"Short newer","timestamp":999} - ]}); - let older_short = serde_json::json!({"entries":[ - {"id":"fffffffffff","title":"Short older","timestamp":50} - ]}); - let output = format!( - "{}\n{}\n{}\n{}\n{}", - serde_json::json!({"entries": first}), - serde_json::json!({"entries": second}), - short, - older_short, - serde_json::json!({"entries":[]}) - ); - let page = feed_results(&output, &requests).unwrap(); - assert!(page.has_more); - assert_eq!( - page.feed_progress - .iter() - .map(|entry| entry.count) - .collect::>(), - [12, 12, 1, 1, 0] - ); - assert_eq!( - page.videos - .iter() - .map(|video| video.id.as_str()) - .collect::>(), - [ - "eeeeeeeeeee", - "aaaaaaaaaaa", - "ddddddddddd", - "bbbbbbbbbbb", - "fffffffffff", - "ccccccccccc" - ] - ); - assert_eq!(page.videos[1].title, "Newest duplicate"); - assert_eq!(page.videos.last().unwrap().timestamp, None); - - let requests = FeedCursor::default().plan(3).unwrap(); - let exhausted = feed_results( - &format!( - "{}\n{}\n{}", - short, - older_short, - serde_json::json!({"entries":[]}) - ), - &requests, - ) - .unwrap(); - assert!(!exhausted.has_more); - assert_eq!(exhausted.videos.len(), 2); - } - - #[test] - fn channel_handles_require_the_requested_identity_and_a_valid_handle() { - let id = "UChAQQ9at2MDlXjvQX3vL3Yw"; - for handle in ["@ch.1676", "@敷嶋てとら", "@hontame_"] { - let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); - assert_eq!(channel_handle(&metadata, id).unwrap(), handle); - assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err()); - } - for handle in [ - None, - Some("ch.1676"), - Some("@bad/handle"), - Some("@bad\u{1b}"), - ] { - let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); - assert!(channel_handle(&metadata, id).is_err()); - } - assert!(channel_handle("not JSON", id).is_err()); - } - - #[test] - fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() { - let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#; - assert_eq!( - full_description(metadata, "jNQXAC9IVRw").unwrap(), - "First paragraph.\n\n日本語\tsecond paragraph.\nLast line." - ); - assert!(full_description(metadata, "rQ_J9WH6CGk").is_err()); - assert!(full_description("not JSON", "jNQXAC9IVRw").is_err()); - assert_eq!( - full_description(r#"{"id":"jNQXAC9IVRw","description":null}"#, "jNQXAC9IVRw").unwrap(), - "" - ); - } -} +#[path = "../tests/unit/youtube.rs"] +mod tests; diff --git a/tests/unit/app.rs b/tests/unit/app.rs new file mode 100644 index 0000000..50ee597 --- /dev/null +++ b/tests/unit/app.rs @@ -0,0 +1,5836 @@ +use super::*; + +impl App { + fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let path = std::env::temp_dir() + .join(format!( + "tubular-ui-state-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )) + .join("config.toml"); + Self::with_state( + options, + config_path, + config, + video_state::State::open(&path).unwrap(), + ) + } +} + +#[test] +fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel() { + use std::ops::ControlFlow::{Break, Continue}; + let mut selected = 0; + assert_eq!( + provider_selection(&mut selected, Some(Action::SelectorPrevious)), + Continue(()) + ); + for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] { + if action == Action::SelectorNext { + assert_eq!( + provider_selection(&mut selected, Some(action)), + Continue(()) + ); + assert_eq!( + provider_selection(&mut selected, Some(action)), + Continue(()) + ); + } + let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen)) + else { + panic!("Selected provider must open"); + }; + assert_eq!(choice, miruro); + let mut options = Options::parse_from(["tubular", "initial query"]); + options.miruro = choice; + assert_eq!(options.query.as_deref(), Some("initial query")); + assert_eq!(options.miruro, miruro); + if !miruro { + let app = App::new(options, PathBuf::new(), config::Config::default()); + assert_eq!(app.views[0].query, "initial query"); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + } + } + for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] { + assert_eq!(provider_selection(&mut selected, Some(action)), Break(None)); + } + assert_eq!(provider_selection(&mut selected, None), Continue(())); +} + +fn app(args: &[&str]) -> App { + App::new( + Options::parse_from(args), + PathBuf::new(), + config::Config::default(), + ) +} + +#[test] +fn youtube_collections_isolate_mixed_provider_rows_and_removal_ids() { + let mut app = app(&["tubular"]); + let youtube = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"YouTube","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@creator"}]}"#, + ).unwrap().videos.remove(0); + let mut miruro = youtube.clone(); + miruro.id = "miruro:catalog_ID:1".into(); + miruro.title = "Miruro episode".into(); + miruro.channel = Some("Miruro".into()); + miruro.channel_id = None; + miruro.uploader_id = None; + for kind in [ + CollectionKind::WatchLater, + CollectionKind::Saved, + CollectionKind::Watching, + ] { + app.video_state.save(kind, &youtube).unwrap(); + app.video_state.save(kind, &miruro).unwrap(); + app.browse_collection(kind); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert_eq!(app.views[app.view_index].videos[0].id, youtube.id); + assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); + app.remove_collection_bulk(); + assert_eq!( + app.collection_removal.as_ref().unwrap().ids.as_slice(), + std::slice::from_ref(&youtube.id) + ); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(!app.video_state.contains(kind, &youtube.id)); + assert!(app.video_state.contains(kind, &miruro.id)); + assert!(app.views[app.view_index].videos.is_empty()); + app.video_state.save(kind, &youtube).unwrap(); + app.rebuild_collections(); + app.remove_collection(); + assert!(!app.video_state.contains(kind, &youtube.id)); + assert!(app.video_state.contains(kind, &miruro.id)); + } +} + +#[test] +fn watching_direct_bindings_respect_modals_and_offline_history() { + for (label, overrides, binding) in [ + ("default", "", Some(KeyCode::Char('C'))), + ( + "remapped", + "[keys]\nview.watching.browse = 'f8'\n", + Some(KeyCode::F(8)), + ), + ("disabled", "[keys]\nview.watching.browse = []\n", None), + ] { + let mut app = rename_app(&format!("watching-{label}"), overrides); + for view in &mut app.views { + view.loaded = true; + } + app.mode = Mode::Insert; + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.views[0].query, "C"); + rename_press(&mut app, KeyCode::Esc); + app.open_browser(BrowserKind::Categories); + rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); + assert!(app.browser.is_some()); + assert_eq!(app.view_index, 0); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('?')); + rename_press(&mut app, binding.unwrap_or(KeyCode::Char('C'))); + assert!(app.help_popup.is_some()); + assert_eq!(app.view_index, 0); + rename_press(&mut app, KeyCode::Esc); + if binding != Some(KeyCode::Char('C')) { + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.view_index, 0); + } + if let Some(binding) = binding { + rename_press(&mut app, binding); + let index = app.view_index; + assert_eq!(app.views[index].source, ViewSource::Watching); + assert_eq!(app.temporary_view, Some(ViewSource::Watching)); + assert_eq!(app.key_context(), Context::CollectionNormal); + assert!(app.browser.is_none()); + app.load_page(); + let config = std::mem::take(&mut app.config); + app.apply_config(config); + assert_eq!(app.views[app.view_index].source, ViewSource::Watching); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.view_index, index); + rename_press(&mut app, KeyCode::Char('+')); + assert_eq!(app.view_index, 0); + rename_press(&mut app, binding); + assert_eq!(app.view_index, index); + assert!(app.job.is_none()); + assert!(!app.views[index].has_more); + } + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } +} + +#[test] +fn watching_progress_preserves_selection_and_removal_is_collection_only() { + use std::io::Write; + fn report(path: &std::path::Path, id: &str, position: u32) { + let mut options = std::fs::OpenOptions::new(); + options.append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + writeln!( + options.open(path).unwrap(), + "{{\"version\":1,\"event\":\"progress\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":true}}" + ).unwrap(); + } + let mut app = rename_app("watching-progress", ""); + for view in &mut app.views { + view.loaded = true; + } + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Third","uploader_id":"@third"}]}"#, + ).unwrap().videos; + let reports: Vec<_> = videos + .iter() + .map(|video| { + app.video_state.save(CollectionKind::Saved, video).unwrap(); + app.video_state + .save(CollectionKind::WatchLater, video) + .unwrap(); + let path = app.video_state.progress_file(video).unwrap(); + video_state::register_identity(&path, &video.id).unwrap(); + report(&path, &video.id, 1); + path + }) + .collect(); + app.update(); + rename_press(&mut app, KeyCode::Char('C')); + assert_eq!(app.views[app.view_index].videos.len(), 3); + let second = app.views[app.view_index] + .videos + .iter() + .position(|video| video.id == videos[1].id) + .unwrap(); + app.views[app.view_index].selection.select(Some(second)); + app.preview = Some(PreviewMode::Description); + report(&reports[0], &videos[0].id, 95); + app.update(); + let view = &app.views[app.view_index]; + assert_eq!(view.videos.len(), 2); + assert_eq!( + view.videos[view.selection.selected().unwrap()].id, + videos[1].id + ); + assert_eq!(app.preview, Some(PreviewMode::Description)); + report(&reports[1], &videos[1].id, 95); + app.update(); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!(app.preview.is_none()); + assert!(app.status.contains("1 videos")); + rename_press(&mut app, KeyCode::Char('x')); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + report(&reports[2], &videos[2].id, 2); + app.update(); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + let fresh = app.video_state.progress_file(&videos[2]).unwrap(); + video_state::register_identity(&fresh, &videos[2].id).unwrap(); + report(&fresh, &videos[2].id, 1); + app.update(); + rename_press(&mut app, KeyCode::Char('X')); + assert_eq!( + app.collection_removal.as_ref().unwrap().ids, + [videos[2].id.clone()] + ); + rename_press(&mut app, KeyCode::Enter); + assert!(app.collection_removal.is_some()); + rename_press(&mut app, KeyCode::Char('y')); + assert!(app.video_state.videos(CollectionKind::Watching).is_empty()); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 3); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3); + assert!(app.video_state.is_watched(&videos[0].id)); + assert!(app.video_state.is_watched(&videos[1].id)); + assert!(!app.video_state.is_watched(&videos[2].id)); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn direct_jumps_reuse_cached_views_and_navigation_with_configured_bindings() { + for (document, search, feed) in [ + ("", KeyCode::Char('/'), KeyCode::Char('F')), + ( + "[keys]\nview.search = 'f6'\nview.feed = 'f7'\n", + KeyCode::F(6), + KeyCode::F(7), + ), + ] { + let mut app = app(&["tubular", "global draft"]); + if !document.is_empty() { + let document = document.parse::().unwrap(); + app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); + } + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"# + ).unwrap().videos; + app.views.push(View::new( + "Temporary".into(), + ViewSource::Channel("creator".into()), + )); + for view in &mut app.views { + view.loaded = true; + view.videos = videos.clone(); + view.selection.select(Some(1)); + view.next_start = 61; + } + app.view_index = 2; + app.preview = Some(PreviewMode::Description); + rename_press(&mut app, feed); + assert_eq!(app.view_index, 1); + assert_eq!(app.mode, Mode::Normal); + assert!(app.preview.is_none()); + rename_press(&mut app, search); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[0].query, "global draft"); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.view_index, 2); + rename_press(&mut app, KeyCode::Char('+')); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Char('+')); + assert_eq!(app.view_index, 0); + for view in &app.views { + assert_eq!(view.videos[1].id, "l-GaaivVJCE"); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.next_start, 61); + } + assert!(app.job.is_none()); + for source in [ + ViewSource::Channel("creator".into()), + ViewSource::SavedVideos(None), + ViewSource::WatchLater(None), + ViewSource::Watching, + ] { + app.views[2].source = source; + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.view_index = 2; + app.preview = preview; + rename_press(&mut app, search); + assert_eq!(app.view_index, 0); + assert_eq!(app.mode, Mode::Normal); + assert!(app.preview.is_none()); + rename_press(&mut app, feed); + assert_eq!(app.view_index, 1); + } + } + app.view_index = 0; + if !document.is_empty() { + app.view_index = 2; + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.view_index, 2, "remapping replaces both defaults"); + } + let document = "[keys]\nview.search = []\nview.feed = []\n" + .parse::() + .unwrap(); + app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap(); + app.view_index = 2; + rename_press(&mut app, search); + rename_press(&mut app, feed); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.view_index, 2); + assert!(app.job.is_none()); + } +} + +#[test] +fn direct_jump_keys_remain_literal_or_inert_in_modals() { + let mut app = app(&["tubular"]); + app.views[1].loaded = true; + app.view_index = 1; + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.views[0].query, "/F"); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Esc); + for kind in [BrowserKind::Categories, BrowserKind::Channels] { + app.open_browser(kind); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.browser.as_ref().unwrap().query, "/F"); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Esc); + } + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@creator".into(), + channel: "creator".into(), + }, + "Creator".into(), + Default::default(), + )); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.category_dialog.as_ref().unwrap().query, "/F"); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('n')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("/F") + ); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('?')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert!(app.help_popup.is_some()); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Esc); + app.collection_removal = Some(CollectionRemoval { + kind: CollectionKind::Saved, + ids: Vec::new(), + category: None, + filter: None, + text: "Confirm removal".into(), + }); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert!(app.collection_removal.is_some()); + assert_eq!(app.view_index, 1); + rename_press(&mut app, KeyCode::Esc); + let mut group = View::new("Combined".into(), ViewSource::CategoryGroup(vec![])); + group.loaded = true; + app.views.push(group); + let group_index = app.views.len() - 1; + app.view_index = group_index; + rename_press(&mut app, KeyCode::Char('P')); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('F')); + assert_eq!(app.category_name_input.as_ref().unwrap().name, "/F"); + assert_eq!(app.view_index, group_index); +} + +#[test] +fn category_and_group_queries_own_drafts_pages_and_errors_without_touching_search() { + for source in [ + ViewSource::Category("Alpha".into()), + ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]), + ] { + let mut app = app(&["tubular", "global draft"]); + app.views[0].submitted_query = "global submitted".into(); + app.views[0].loaded = true; + app.views[0].next_start = 61; + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"l-GaaivVJCE","title":"Global cached result"}]}"#, + ) + .unwrap() + .videos; + app.views[0].selection.select(Some(0)); + app.apply_config(config::Config { + categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), + ..Default::default() + }); + app.browse_view("Scoped creators".into(), source.clone()); + let index = app.view_index; + let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached"}]}"#; + app.views[index].feed_cursor.plan(1).unwrap(); + app.views[index] + .accept_page(feed_fixture(output).unwrap(), true) + .unwrap(); + rename_press(&mut app, KeyCode::Char('i')); + app.paste(" robotics 日本語 "); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.views[index].submitted_query, "robotics 日本語"); + assert_eq!(app.view_index, index); + assert_eq!(app.mode, Mode::Normal); + assert!(app.views[index].videos.is_empty()); + assert_eq!(app.views[index].selection.selected(), None); + assert_eq!(app.views[index].next_start, 1); + assert_eq!(app.views[index].feed_cursor.plan(1).unwrap()[0].start, 1); + app.finish_page(Err("Scoped request failed".into())); + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.view_index, index); + assert!(app.views[index].page_error.is_some()); + assert!( + app.job.is_none(), + "repeated failed query must not retry globally" + ); + app.views[index] + .accept_page(feed_fixture(output).unwrap(), true) + .unwrap(); + let next_start = app.views[index].next_start; + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.views[index].videos[0].id, "jNQXAC9IVRw"); + assert_eq!(app.views[index].next_start, next_start); + rename_press(&mut app, KeyCode::Char('i')); + app.paste(" draft"); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(app.views[index].submitted_query, "robotics 日本語"); + assert!(app.views[index].query.ends_with(" draft")); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); + assert!(text.contains("Category search")); + assert!(!text.contains("YouTube search")); + rename_press(&mut app, KeyCode::Char('/')); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.views[app.view_index].source, source); + assert!(app.views[index].query.ends_with(" draft")); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.view_index, index); + assert!(app.views[index].submitted_query.is_empty()); + assert!(app.views[index].videos.is_empty()); + assert_eq!(app.views[index].next_start, 1); + assert!(app.job.is_none()); + assert_eq!(app.views[0].query, "global draft"); + assert_eq!(app.views[0].submitted_query, "global submitted"); + assert_eq!(app.views[0].next_start, 61); + assert_eq!(app.views[0].videos[0].id, "l-GaaivVJCE"); + assert_eq!(app.views[0].selection.selected(), Some(0)); + } +} + +#[test] +fn other_sources_submit_to_general_search_and_blank_global_query_is_a_noop() { + let mut app = app(&["tubular", " cached global "]); + app.views[0].submitted_query = "cached global".into(); + app.views[0].loaded = true; + app.views[0].next_start = 61; + for source in [ + ViewSource::Feed, + ViewSource::Channel("creator".into()), + ViewSource::WatchLater(None), + ViewSource::SavedVideos(None), + ] { + let mut view = View::new("Other".into(), source); + view.loaded = true; + app.views.push(view); + app.view_index = app.views.len() - 1; + rename_press(&mut app, KeyCode::Char('i')); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[0].next_start, 61); + assert!(app.job.is_none()); + } + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + rename_press(&mut app, KeyCode::Enter); + assert!(app.views[0].query.is_empty()); + assert_eq!(app.views[0].submitted_query, "cached global"); + assert_eq!(app.views[0].next_start, 61); + assert!(app.job.is_none()); +} + +// Unrelated UI fixtures still parse real feed envelopes with a shared-budget plan. +fn feed_fixture(output: &str) -> Result { + let channels = serde_json::Deserializer::from_str(output) + .into_iter::() + .count(); + let requests = youtube::FeedCursor::default().plan(channels).unwrap(); + youtube::feed_results(output, &requests) +} + +fn finish_feed_fixture(app: &mut App, output: &str, full: bool) { + let view = &mut app.views[app.view_index]; + let channels = view + .source + .channels(&app.config, &app.feed_channels, &view.group_channels) + .len(); + view.feed_requests = view.feed_cursor.plan(channels).unwrap(); + let mut envelopes: Vec<_> = serde_json::Deserializer::from_str(output) + .into_iter::() + .map(Result::unwrap) + .collect(); + assert!(envelopes.len() <= view.feed_requests.len()); + envelopes.resize_with( + view.feed_requests.len(), + || serde_json::json!({"entries":[]}), + ); + if full { + for (envelope, request) in envelopes.iter_mut().zip(&view.feed_requests) { + let entries = envelope["entries"].as_array_mut().unwrap(); + assert!(entries.len() <= request.count); + entries.resize(request.count, serde_json::Value::Null); + } + } + let output = envelopes + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + app.finish_page(Ok(output)); +} + +fn choose_combined(app: &mut App, key: KeyEvent) { + app.key(key); + while app + .browser + .as_ref() + .unwrap() + .selection + .selected() + .unwrap_or(0) + != 0 + { + app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +fn rename_app(label: &str, keys: &str) -> App { + let root = std::env::temp_dir().join(format!("tubular-rename-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("config.toml"); + std::fs::write(&path, format!( + "[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@Alpha']\nOther = []\n{keys}" + )).unwrap(); + App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ) +} + +fn rename_press(app: &mut App, code: KeyCode) { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn rename_to(app: &mut App, name: &str) { + app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste(name); + rename_press(app, KeyCode::Enter); + assert!(app.category_name_input.is_none()); +} + +fn group_app(label: &str, keys: &str) -> App { + let mut app = rename_app(&format!("group-{label}"), keys); + let config_text = |members: &str| { + format!( + "# keep this comment\n[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Alias' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@Beta' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n{members}\n{keys}" + ) + }; + std::fs::write( + &app.config_path, + config_text("Alpha = []\nBeta = []\nGamma = []"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Down); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Enter); + std::fs::write( + &app.config_path, + config_text("Alpha = ['@Alpha']\nBeta = ['@Alias', '@Beta']\nGamma = []"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + app.views[app.view_index].loaded = true; + app +} + +#[test] +fn category_checks_survive_filters_and_enter_uses_zero_one_or_many_choices() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Gamma".into(), vec![]), + ] + .into(), + ..Default::default() + }); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Char('i')); + app.paste("beta"); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste("no match"); + assert!(app.browser.as_ref().unwrap().matches.is_empty()); + rename_press(&mut app, KeyCode::Enter); + let combined = app.views[app.view_index].source.clone(); + assert_eq!( + combined, + ViewSource::CategoryGroup(vec!["Alpha".into(), "Beta".into()]) + ); + rename_press(&mut app, KeyCode::Char('c')); + assert_eq!( + app.browser + .as_ref() + .unwrap() + .checked + .iter() + .map(String::as_str) + .collect::>(), + ["Alpha", "Beta"] + ); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Enter); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Beta".into()) + ); + + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Down); + rename_press(&mut app, KeyCode::Enter); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Gamma".into()) + ); + let before = app.views[app.view_index].source.clone(); + let history = app.view_history.clone(); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(app.views[app.view_index].source, before); + assert_eq!(app.view_history, history); + rename_press(&mut app, KeyCode::Char('c')); + assert!(app.browser.as_ref().unwrap().checked.is_empty()); + rename_press(&mut app, KeyCode::Char('i')); + app.paste("no match"); + rename_press(&mut app, KeyCode::Enter); + assert!(app.browser.is_some()); + assert_eq!(app.views[app.view_index].source, before); +} + +#[test] +fn combined_feed_deduplicates_overlapping_rows_and_restores_pages_through_history() { + let mut app = group_app("pages", ""); + let group = app.views[app.view_index].source.clone(); + assert_eq!( + app.views[app.view_index].group_channels, + [ + "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", + "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA", + ] + ); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared","timestamp":300},{"id":"l-GaaivVJCE","title":"Old","timestamp":100}]} +{"entries":[{"id":"jNQXAC9IVRw","title":"Shared again","timestamp":300},{"id":"rQ_J9WH6CGk","title":"Middle","timestamp":200}]}"#, + true, + ); + assert_eq!( + app.views[app.view_index] + .videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE"] + ); + rename_press(&mut app, KeyCode::Down); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"l-GaaivVJCE","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Last","timestamp":50}]}"#, + false, + ); + let next_start = app.views[app.view_index].next_start; + let names = vec!["Alpha".into(), "Gamma".into()]; + let mut replacement = View::new(group_name(&names), ViewSource::CategoryGroup(names)); + replacement.group_channels = app.config.categories["Alpha"].clone(); + replacement.loaded = true; + app.views.push(replacement); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Down); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Down); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Enter); + assert_eq!( + app.views[app.view_index].source, + ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()]) + ); + assert_eq!( + app.visible_views() + .filter(|(_, view)| matches!(view.source, ViewSource::CategoryGroup(_))) + .count(), + 1 + ); + rename_press(&mut app, KeyCode::Char('-')); + let view = &app.views[app.view_index]; + assert_eq!(view.source, group); + assert_eq!(view.videos.len(), 4); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.next_start, next_start); + assert!(app.job.is_none()); + rename_press(&mut app, KeyCode::Char('+')); + assert_eq!( + app.views[app.view_index].source, + ViewSource::CategoryGroup(vec!["Alpha".into(), "Gamma".into()]) + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn combined_cache_resets_only_for_changed_resolved_union_and_drops_deleted_members() { + let mut app = group_app("config", ""); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, + false, + ); + let group = app.views[app.view_index].source.clone(); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + std::fs::write( + &app.config_path, + original + .replace("Alpha = ['@Alpha']", "Alpha = ['@Beta']") + .replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alpha']"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + let view = &app.views[app.view_index]; + assert_eq!(view.source, group); + assert_eq!(view.videos.len(), 1); + assert_eq!(view.next_start, PAGE_SIZE + 1); + std::fs::write( + &app.config_path, + original.replace("Beta = ['@Alias', '@Beta']", "Beta = ['@Alias']"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + let view = &app.views[app.view_index]; + assert_eq!(view.source, group); + assert_eq!(view.videos[0].title, "Cached"); + assert!(!view.loaded); + assert_eq!(view.next_start, 1); + assert_eq!(view.group_channels.len(), 1); + app.views[app.view_index].loaded = true; + app.views[0].loaded = true; + app.switch_view(0); + std::fs::write( + &app.config_path, + original.replace("Beta = ['@Alias', '@Beta']\n", ""), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + assert!(!app.views.iter().any(|view| view.source == group)); + assert!(app.temporary_view.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn checked_category_rename_preserves_canonical_group_pin_selection_and_history() { + let mut app = group_app("rename", ""); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, + false, + ); + app.views[app.view_index].query = "draft astronomy".into(); + app.views[app.view_index].submitted_query = "astronomy".into(); + let old = app.views[app.view_index].source.clone(); + app.view_history.push(old.clone()); + app.forward_history.push(old); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Char('i')); + app.paste("Alpha"); + rename_press(&mut app, KeyCode::Esc); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + std::fs::write(&app.config_path, format!("{original}Extra = []\n")).unwrap(); + rename_press(&mut app, KeyCode::Char('p')); + assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2); + assert!(app.browser.as_ref().unwrap().checked.contains("Alpha")); + assert!(!app.config.pinned_categories.contains(&"Alpha".into())); + rename_press(&mut app, KeyCode::Char('p')); + rename_to(&mut app, "Zeta"); + let renamed = ViewSource::CategoryGroup(vec!["Beta".into(), "Zeta".into()]); + assert_eq!(app.views[app.view_index].source, renamed); + assert_eq!(app.temporary_view, Some(renamed.clone())); + assert!(app.view_history.contains(&renamed)); + assert!(app.forward_history.contains(&renamed)); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert_eq!(app.views[app.view_index].query, "draft astronomy"); + assert_eq!(app.views[app.view_index].submitted_query, "astronomy"); + assert_eq!(app.config.pinned_categories, ["Zeta"]); + let browser = app.browser.as_ref().unwrap(); + assert_eq!( + browser + .checked + .iter() + .map(String::as_str) + .collect::>(), + ["Beta", "Zeta"] + ); + assert_eq!(browser.selected_choice().unwrap().0, "Zeta"); + rename_press(&mut app, KeyCode::Enter); + assert_eq!(app.views[app.view_index].source, renamed); + assert!(app.job.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn saving_combined_view_copies_snapshot_and_preserves_cached_state_without_pinning() { + let mut app = group_app("save", ""); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":200},{"id":"l-GaaivVJCE","title":"Second","timestamp":100}]}"#, + true, + ); + rename_press(&mut app, KeyCode::Down); + let source = app.views[app.view_index].source.clone(); + let channels = app.views[app.view_index].group_channels.clone(); + let next_requests = app.views[app.view_index] + .feed_cursor + .plan(channels.len()) + .unwrap(); + app.views[app.view_index].feed_requests = next_requests.clone(); + app.views[app.view_index].prefetch_requested = true; + app.views[app.view_index].has_more = true; + app.views[app.view_index].page_error = Some("keep failure".into()); + app.views[app.view_index].query = "draft physics".into(); + app.views[app.view_index].submitted_query = "physics".into(); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + rename_press(&mut app, KeyCode::Char('P')); + app.paste("Saved union"); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + assert_eq!(app.views[app.view_index].source, source); + for invalid in [" ", "Alpha", "Bad\nname"] { + rename_press(&mut app, KeyCode::Char('P')); + app.category_name_input.as_mut().unwrap().name = invalid.into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + assert_eq!(app.views[app.view_index].source, source); + rename_press(&mut app, KeyCode::Esc); + } + rename_press(&mut app, KeyCode::Char('P')); + app.paste("OnDisk"); + let external = format!("{original}OnDisk = []\n"); + std::fs::write(&app.config_path, &external).unwrap(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external); + assert_eq!(app.views[app.view_index].source, source); + rename_press(&mut app, KeyCode::Esc); + std::fs::write(&app.config_path, &original).unwrap(); + rename_press(&mut app, KeyCode::Char('P')); + app.paste(" Saved union "); + // External edits do not change the union being saved. + std::fs::write( + &app.config_path, + original.replace("Alpha = ['@Alpha']", "Alpha = []"), + ) + .unwrap(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.is_none()); + let view = &app.views[app.view_index]; + assert_eq!(view.source, ViewSource::Category("Saved union".into())); + assert_eq!(view.query, "draft physics"); + assert_eq!(view.submitted_query, "physics"); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.videos.len(), 2); + assert_eq!(view.next_start, PAGE_SIZE + 1); + assert_eq!(view.feed_requests, next_requests); + assert_eq!( + view.feed_cursor.clone().plan(channels.len()).unwrap(), + next_requests + ); + assert!(view.prefetch_requested); + assert!(view.has_more); + assert_eq!(view.page_error.as_deref(), Some("keep failure")); + assert!(app.job.is_none()); + assert_eq!(app.config.categories["Saved union"], channels); + assert_eq!(app.config.categories["Beta"].len(), 2); + assert!(app.config.categories["Alpha"].is_empty()); + assert_eq!(app.config.pinned_categories, ["Alpha"]); + assert_eq!( + app.temporary_view, + Some(ViewSource::Category("Saved union".into())) + ); + let disk = std::fs::read_to_string(&app.config_path) + .unwrap() + .parse::() + .unwrap(); + assert_eq!( + disk["categories"]["Saved union"] + .as_array() + .unwrap() + .iter() + .map(|value| value.as_str().unwrap()) + .collect::>(), + ["@Beta", "@Alias"] + ); + assert!( + std::fs::read_to_string(&app.config_path) + .unwrap() + .starts_with("# keep this comment") + ); + rename_press(&mut app, KeyCode::Char('-')); + assert_eq!(app.views[app.view_index].source, source); + assert_eq!(app.views[app.view_index].group_channels, channels); + let saved = std::fs::read_to_string(&app.config_path).unwrap(); + std::fs::write( + &app.config_path, + saved.replace("Beta = ['@Alias', '@Beta']", "Beta = []"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + assert!(app.views[app.view_index].group_channels.is_empty()); + assert_eq!(app.views[app.view_index].query, "draft physics"); + assert_eq!(app.views[app.view_index].submitted_query, "physics"); + assert_eq!(app.config.categories["Saved union"], channels); + app.forward_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Saved union".into()) + ); + assert_eq!(app.views[app.view_index].videos.len(), 2); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert_eq!(app.views[app.view_index].submitted_query, "physics"); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn configurable_group_controls_share_text_editing_and_are_inert_outside_groups() { + let keys = "[keys]\nselector.toggle = 'f5'\ncategory.save = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n"; + let mut app = group_app("remapped", ""); + let doc = keys.parse::().unwrap(); + app.config.keys = keys::Bindings::from_toml(doc.get("keys")).unwrap(); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + rename_press(&mut app, KeyCode::Char('c')); + rename_press(&mut app, KeyCode::Char(' ')); + assert_eq!(app.browser.as_ref().unwrap().checked.len(), 2); + rename_press(&mut app, KeyCode::F(5)); + assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::SHIFT)); + app.paste("P R"); + assert_eq!(app.browser.as_ref().unwrap().query, " P R"); + assert_eq!(app.browser.as_ref().unwrap().checked.len(), 1); + rename_press(&mut app, KeyCode::F(8)); + rename_press(&mut app, KeyCode::Esc); + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + rename_press(&mut app, KeyCode::Char('P')); + assert!(app.category_name_input.is_none()); + rename_press(&mut app, KeyCode::F(6)); + assert!(app.category_name_input.is_some()); + rename_press(&mut app, KeyCode::F(9)); + app.paste("日本語 words"); + rename_press(&mut app, KeyCode::F(10)); + rename_press(&mut app, KeyCode::F(11)); + app.key(KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT)); + app.paste("\nR\t"); + assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語P R "); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.is_some()); + rename_press(&mut app, KeyCode::F(8)); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + } + app.views[0].loaded = true; + app.switch_view(0); + rename_press(&mut app, KeyCode::F(6)); + assert!(app.category_name_input.is_none()); + for kind in [ + BrowserKind::Channels, + BrowserKind::WatchLater, + BrowserKind::SavedVideos, + ] { + app.open_browser(kind); + rename_press(&mut app, KeyCode::F(5)); + rename_press(&mut app, KeyCode::F(6)); + assert!(app.browser.as_ref().unwrap().checked.is_empty()); + assert!(app.category_name_input.is_none()); + rename_press(&mut app, KeyCode::Esc); + } + app.back_view(); + rename_press(&mut app, KeyCode::F(6)); + app.paste("Remapped"); + rename_press(&mut app, KeyCode::F(7)); + assert!(app.category_name_input.is_none()); + assert!(app.config.categories.contains_key("Remapped")); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn combined_sources_do_not_infer_a_category_for_scoped_actions() { + let mut app = group_app("scopes", ""); + let source = app.views[app.view_index].source.clone(); + for key in [ + KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT), + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + ] { + app.key(key); + assert_eq!(app.views[app.view_index].source, source); + assert!(app.category_dialog.is_none()); + assert!(app.category_name_input.is_none()); + assert!(app.browser.is_none()); + } + for key in ['W', 'V'] { + rename_press(&mut app, KeyCode::Char(key)); + assert!(app.browser.is_some()); + assert!(app.browser.as_ref().unwrap().checked.is_empty()); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(app.views[app.view_index].source, source); + } + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn empty_combined_view_can_be_saved_as_an_empty_category() { + let mut app = group_app("empty-save", ""); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + std::fs::write( + &app.config_path, + original + .replace("Alpha = ['@Alpha']", "Alpha = []") + .replace("Beta = ['@Alias', '@Beta']", "Beta = []"), + ) + .unwrap(); + app.apply_config(config::load(&app.config_path).unwrap()); + rename_press(&mut app, KeyCode::Char('P')); + app.paste("Empty union"); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.is_none()); + assert!(app.config.categories["Empty union"].is_empty()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Empty union".into()) + ); + assert!(app.views[app.view_index].loaded); + assert!(app.views[app.view_index].videos.is_empty()); + assert!(app.job.is_none()); + assert_eq!(app.config.pinned_categories, ["Alpha"]); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn category_checkboxes_and_dim_pins_remain_distinct_when_highlighted() { + use ratatui::{Terminal, backend::TestBackend, style::Modifier}; + let mut app = group_app("markers", ""); + rename_press(&mut app, KeyCode::Char('c')); + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let row = (0..buffer.area.height) + .find_map(|y| { + let text: String = (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + text.find("[x] Alpha p") + .map(|byte| (text[..byte].chars().count() as u16, y)) + }) + .expect("checked and pinned category row"); + assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM)); + rename_press(&mut app, KeyCode::Char(' ')); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = (0..buffer.area.width) + .map(|x| buffer[(x, row.1)].symbol()) + .collect(); + assert!(text.contains("[ ] Alpha p")); + assert!(buffer[(row.0 + 10, row.1)].modifier.contains(Modifier::DIM)); + assert!(app.config.pinned_categories.contains(&"Alpha".into())); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn category_rename_preserves_pins_cached_pages_collection_scopes_and_history() { + let mut app = rename_app("cache", ""); + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"# + ).unwrap().videos; + for source in [ + ViewSource::WatchLater(Some("Alpha".into())), + ViewSource::SavedVideos(Some("Alpha".into())), + ] { + let mut view = View::new("Alpha collection".into(), source); + view.videos = videos.clone(); + view.selection.select(Some(1)); + view.loaded = true; + view.watch_filter = WatchFilter::Watched; + app.views.push(view); + } + app.view_index = 2; + let view = &mut app.views[2]; + view.query = " draft robotics ".into(); + view.submitted_query = "robotics".into(); + view.videos = videos.clone(); + view.feed_requests = view.feed_cursor.plan(1).unwrap(); + view.prefetch_requested = true; + view.selection.select(Some(1)); + view.loaded = true; + view.next_start = 41; + view.has_more = true; + view.page_error = Some("keep page failure".into()); + app.temporary_view = Some(ViewSource::SavedVideos(Some("Alpha".into()))); + app.view_history = vec![ViewSource::WatchLater(Some("Alpha".into()))]; + app.forward_history = vec![ViewSource::SavedVideos(Some("Alpha".into()))]; + rename_to(&mut app, "Zeta"); + let view = &app.views[app.view_index]; + assert_eq!(view.source, ViewSource::Category("Zeta".into())); + assert_eq!(view.name, "Zeta"); + assert_eq!(view.query, " draft robotics "); + assert_eq!(view.submitted_query, "robotics"); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.videos.len(), 2); + assert_eq!(view.feed_requests.len(), 1); + assert!(view.prefetch_requested); + assert_eq!(view.next_start, 41); + assert!(view.loaded && view.has_more); + assert_eq!(view.page_error.as_deref(), Some("keep page failure")); + assert_eq!(app.config.pinned_categories, ["Zeta"]); + assert_eq!(app.views[2].source, ViewSource::Category("Other".into())); + assert_eq!( + app.temporary_view, + Some(ViewSource::SavedVideos(Some("Zeta".into()))) + ); + assert_eq!( + app.view_history, + [ViewSource::WatchLater(Some("Zeta".into()))] + ); + assert_eq!( + app.forward_history, + [ViewSource::SavedVideos(Some("Zeta".into()))] + ); + for view in &app.views { + if matches!( + view.source, + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ) { + assert_eq!(view.source.category(), Some("Zeta")); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.watch_filter, WatchFilter::Watched); + assert_eq!(view.videos.len(), 2); + } + } + app.back_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Zeta".into())) + ); + app.forward_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Zeta".into()) + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn rename_selectors_keep_renamed_match_selected_and_ignore_noncategory_rows() { + for (label, kind) in [ + ("categories", BrowserKind::Categories), + ("later", BrowserKind::WatchLater), + ("saved", BrowserKind::SavedVideos), + ("channels", BrowserKind::Channels), + ] { + let mut app = rename_app(label, ""); + app.open_browser(kind); + if kind != BrowserKind::Categories { + rename_press(&mut app, KeyCode::Char('R')); + assert!(app.category_name_input.is_none()); + } + if kind == BrowserKind::Channels { + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + continue; + } + let browser = app.browser.as_mut().unwrap(); + browser.query = "alp".into(); + browser.refilter(); + rename_press(&mut app, KeyCode::Char('R')); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(app.browser.as_ref().unwrap().query, "alp"); + rename_to(&mut app, "Zeta"); + let browser = app.browser.as_ref().unwrap(); + assert!(browser.query.is_empty()); + let selected = browser.matches[browser.selection.selected().unwrap()]; + assert_eq!(browser.choices[selected].1.category(), Some("Zeta")); + assert!(app.config.pinned_categories.contains(&"Zeta".into())); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + for (label, source) in [ + ( + "later-current", + ViewSource::WatchLater(Some("Alpha".into())), + ), + ( + "saved-current", + ViewSource::SavedVideos(Some("Alpha".into())), + ), + ] { + let mut app = rename_app(label, ""); + let mut view = View::new("Alpha collection".into(), source); + view.loaded = true; + app.views.push(view); + app.view_index = app.views.len() - 1; + rename_to(&mut app, "Zeta"); + assert_eq!(app.views[app.view_index].source.category(), Some("Zeta")); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } +} + +#[test] +fn rename_validation_keeps_prompt_and_disk_unchanged_including_external_edits() { + let mut app = rename_app("invalid", ""); + app.view_index = 2; + let original = std::fs::read_to_string(&app.config_path).unwrap(); + for name in ["", " ", "Other", "bad\nname"] { + rename_press(&mut app, KeyCode::Char('R')); + app.category_name_input.as_mut().unwrap().name = name.into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Alpha".into()) + ); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + rename_press(&mut app, KeyCode::Esc); + } + rename_to(&mut app, "Alpha"); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + for external in [ + original.replace("Alpha = ['@Alpha']", "Alpha = []"), + original.replace("Alpha = ['@Alpha']", "Removed = ['@Alpha']"), + original.replace("Other = []", "Other = []\nZeta = []"), + "not valid toml = [".into(), + ] { + std::fs::write(&app.config_path, &external).unwrap(); + rename_press(&mut app, KeyCode::Char('R')); + app.category_name_input.as_mut().unwrap().name = "Zeta".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external); + assert!(app.config.categories.contains_key("Alpha")); + rename_press(&mut app, KeyCode::Esc); + } + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn tag_rename_preserves_staging_retargets_bulk_source_and_saves_only_on_tag_submit() { + for bulk in [false, true] { + let mut app = rename_app(if bulk { "bulk" } else { "single" }, ""); + app.view_index = 2; + let channel = app.config.categories["Alpha"][0].clone(); + if bulk { + app.open_bulk_category_picker(); + } else { + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: channel.clone(), + }, + " @Alpha ".into(), + [("Alpha".into(), true), ("Other".into(), false)].into(), + )); + } + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.tags.insert("Staged".into(), true); + dialog.tags.insert("Unchecked".into(), false); + dialog.tags.insert("Other".into(), true); + dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect(); + dialog.query = "Alpha".into(); + dialog.refilter(); + rename_to(&mut app, "Zeta"); + let disk = config::load(&app.config_path).unwrap(); + assert_eq!(disk.categories["Zeta"], std::slice::from_ref(&channel)); + assert!(disk.categories["Other"].is_empty()); + assert!(!disk.categories.contains_key("Staged")); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(dialog.tags["Zeta"] && dialog.tags["Other"] && dialog.tags["Staged"]); + assert!(!dialog.tags["Unchecked"]); + if let CategoryTarget::Category { name, channels } = &dialog.target { + assert_eq!(name, "Zeta"); + assert_eq!(channels, std::slice::from_ref(&channel)); + } + rename_press(&mut app, KeyCode::Esc); + assert!(app.category_dialog.is_none()); + assert!(config::load(&app.config_path).unwrap().categories["Other"].is_empty()); + // Reopen with staged memberships and rename the bulk source a second time; + // saving must use the new identity, not the now-missing original key. + app.category_dialog = Some(CategoryDialog::new( + if bulk { + CategoryTarget::Category { + name: "Zeta".into(), + channels: vec![channel.clone()], + } + } else { + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: channel.clone(), + } + }, + " target ".into(), + [ + ("Zeta".into(), true), + ("Other".into(), true), + ("Staged".into(), true), + ] + .into(), + )); + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.query = "Zeta".into(); + dialog.refilter(); + rename_to(&mut app, "Beta"); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_dialog.is_none()); + let disk = config::load(&app.config_path).unwrap(); + for tag in ["Beta", "Other", "Staged"] { + assert_eq!(disk.categories[tag], std::slice::from_ref(&channel)); + } + assert!(!disk.categories.contains_key("Zeta")); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } +} + +#[test] +fn new_tag_rename_is_local_and_rejects_staged_and_external_collisions() { + let mut app = rename_app("new-tag", ""); + let channel = app.config.categories["Alpha"][0].clone(); + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel, + }, + " @Alpha ".into(), + [ + ("Alpha".into(), true), + ("Other".into(), false), + ("Staged".into(), false), + ] + .into(), + )); + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.query = "Staged".into(); + dialog.refilter(); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + rename_press(&mut app, KeyCode::Char('R')); + app.category_name_input.as_mut().unwrap().name = "Other".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + app.category_name_input.as_mut().unwrap().name = "Alpha".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, format!("{original}OnDisk = []\n")).unwrap(); + app.category_name_input.as_mut().unwrap().name = "OnDisk".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, format!("{original}Staged = []\n")).unwrap(); + app.category_name_input.as_mut().unwrap().name = "Renamed new".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, &original).unwrap(); + app.category_name_input.as_mut().unwrap().name = "Renamed new".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_name_input.is_none()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(!dialog.tags["Renamed new"]); + assert!(!dialog.tags.contains_key("Staged")); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Enter); + assert!( + config::load(&app.config_path) + .unwrap() + .categories + .contains_key("Renamed new") + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn unpinned_category_rename_retains_its_temporary_tab_and_cached_rows() { + let mut app = rename_app("unpinned", ""); + let config = config::set_category_pinned(&app.config_path, "Alpha", false).unwrap(); + app.apply_config(config); + app.view_index = 2; + app.views[2].loaded = true; + app.views[2].next_start = 61; + app.temporary_view = Some(ViewSource::Category("Alpha".into())); + rename_to(&mut app, "Zeta"); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Zeta".into()) + ); + assert_eq!(app.views[app.view_index].next_start, 61); + assert_eq!( + app.temporary_view, + Some(ViewSource::Category("Zeta".into())) + ); + assert!(app.config.pinned_categories.is_empty()); + assert_eq!( + app.visible_views().last().unwrap().1.source, + ViewSource::Category("Zeta".into()) + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn rename_uses_configured_actions_and_uppercase_r_stays_literal_in_all_inputs() { + let keys = "[keys]\ncategory.rename = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n"; + for entry in ["view", "browser", "tags"] { + let mut app = rename_app(&format!("remapped-{entry}"), keys); + app.view_index = 2; + match entry { + "browser" => app.open_browser(BrowserKind::Categories), + "tags" => { + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: app.config.categories["Alpha"][0].clone(), + }, + " target ".into(), + [("Alpha".into(), true), ("Other".into(), false)].into(), + )) + } + _ => {} + } + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert!( + app.category_name_input.is_none(), + "default action is replaced" + ); + rename_press(&mut app, KeyCode::F(6)); + assert_eq!(app.key_context(), Context::CategoryNameInput); + rename_press(&mut app, KeyCode::F(9)); + app.paste("日本語 words"); + rename_press(&mut app, KeyCode::F(10)); + assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語 "); + rename_press(&mut app, KeyCode::F(11)); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + app.paste("\n?\t"); + assert_eq!(app.category_name_input.as_ref().unwrap().name, "日本語R ? "); + assert!(app.help_popup.is_none()); + rename_press(&mut app, KeyCode::F(8)); + assert!(app.category_name_input.is_none()); + assert!(app.config.categories.contains_key("Alpha")); + rename_press(&mut app, KeyCode::F(6)); + rename_press(&mut app, KeyCode::F(9)); + app.paste("Zeta"); + rename_press(&mut app, KeyCode::Enter); + assert!( + app.category_name_input.is_some(), + "default submit is replaced" + ); + rename_press(&mut app, KeyCode::F(7)); + assert!(app.category_name_input.is_none()); + assert!( + config::load(&app.config_path) + .unwrap() + .categories + .contains_key("Zeta") + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + let mut app = rename_app("literal-r", ""); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.views[0].query, "R"); + assert!(app.category_name_input.is_none()); + rename_press(&mut app, KeyCode::Esc); + app.open_browser(BrowserKind::Categories); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.browser.as_ref().unwrap().query, "R"); + assert!(app.category_name_input.is_none()); + app.browser = None; + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: app.config.categories["Alpha"][0].clone(), + }, + " target ".into(), + [("Alpha".into(), true)].into(), + )); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.category_dialog.as_ref().unwrap().query, "R"); + assert!(app.category_name_input.is_none()); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('n')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("R") + ); + assert!(app.category_name_input.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn rename_prompt_cursor_scrolls_by_display_width_without_changing_selection() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = rename_app("rename-cursor", ""); + app.view_index = 2; + rename_press(&mut app, KeyCode::Char('R')); + let mut terminal = Terminal::new(TestBackend::new(24, 16)).unwrap(); + for (text, x) in [ + ("", 3), + ("日本語", 9), + ("e\u{301}", 4), + ("abcdefghijklmnopqrstuvwxyz", 20), + ] { + app.category_name_input.as_mut().unwrap().name = text.into(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 4).into()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Alpha".into()) + ); + } + rename_press(&mut app, KeyCode::Esc); + assert!(app.category_name_input.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); +} + +#[test] +fn collection_removal_is_inert_in_ordinary_views_and_their_previews() { + let mut app = app(&["tubular"]); + app.config.categories.insert("Category".into(), vec![]); + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].videos = videos.clone(); + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + } + for source in [ + ViewSource::Search, + ViewSource::Feed, + ViewSource::Category("Category".into()), + ViewSource::Channel(config::normalize_channel("@creator").unwrap()), + ] { + let mut view = View::new("Ordinary".into(), source); + view.videos = videos.clone(); + view.loaded = true; + view.selection.select(Some(0)); + let index = app.views.len(); + app.views.push(view); + app.switch_view(index); + for (preview, context) in [ + (None, Context::Normal), + (Some(PreviewMode::Info), Context::Info), + (Some(PreviewMode::Description), Context::Description), + ] { + app.preview = preview; + assert_eq!(app.key_context(), context); + assert!(app.current_collection().is_none()); + let status = app.status.clone(); + for key in ['x', 'X', 'D'] { + app.key(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE)); + } + app.remove_collection(); + app.remove_collection_bulk(); + assert!(app.collection_removal.is_none()); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + assert_eq!(app.views[index].selection.selected(), Some(0)); + assert_eq!(app.preview, preview); + assert_eq!(app.status, status); + app.paste("xX"); + assert!(app.views[0].query.is_empty()); + } + } +} + +#[test] +fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_preview() { + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::Saved); + } + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + app.switch_view(0); + app.browse_collection(kind); + for (preview, context, cancel) in [ + (None, Context::CollectionNormal, KeyCode::Char('n')), + ( + Some(PreviewMode::Info), + Context::CollectionInfo, + KeyCode::Esc, + ), + ( + Some(PreviewMode::Description), + Context::CollectionDescription, + KeyCode::Char('n'), + ), + ] { + app.preview = preview; + assert_eq!(app.key_context(), context); + app.key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)); + assert!(app.collection_removal.is_none()); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + assert_eq!(app.preview, None); + let removal = app.collection_removal.as_ref().unwrap(); + assert_eq!(removal.ids.len(), 2); + assert!(removal.category.is_none()); + assert_eq!( + removal.filter, + matches!(kind, CollectionKind::WatchLater).then_some(WatchFilter::All) + ); + let history = app.view_history.clone(); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.paste("y"); + assert!(app.collection_removal.is_some()); + assert!(app.player_launch.is_none()); + assert_eq!(app.view_history, history); + assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); + assert!(app.collection_removal.is_some()); + app.key(KeyEvent::new(cancel, KeyModifiers::NONE)); + assert!(app.collection_removal.is_none()); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + } + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::SearchInput); + app.paste("xX"); + assert_eq!(app.views[0].query, "xX"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::CollectionNormal); + app.views[0].query.clear(); + app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::Help); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + assert!(app.collection_removal.is_none()); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::CollectionNormal); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::SelectorNormal); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::SelectorInput); + app.paste("xX"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::CollectionNormal); + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@creator".into(), + channel: config::normalize_channel("@creator").unwrap(), + }, + "Creator".into(), + [("Category".into(), false)].into(), + )); + assert_eq!(app.key_context(), Context::TagList); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::TagFilter); + app.paste("xX"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::TagInput); + app.paste("xX"); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("xX") + ); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.key_context(), Context::CollectionNormal); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + } +} + +#[test] +fn configured_removal_hints_follow_collection_views_and_previews() { + let mut app = app(&["tubular"]); + let document = + "[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n" + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"}]}"#, + ) + .unwrap() + .videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::Saved); + app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into()); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap(); + for kind in [ + None, + Some(CollectionKind::WatchLater), + Some(CollectionKind::Saved), + ] { + app.switch_view(0); + if let Some(kind) = kind { + app.browse_collection(kind); + } + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect(); + assert_eq!(rendered.contains("F8"), kind.is_some()); + assert_eq!(rendered.contains("F9"), kind.is_some()); + if preview == Some(PreviewMode::Info) { + let popup = preview_area(buffer.area); + let title: String = (popup.x..popup.right()) + .map(|x| buffer[(x, popup.y)].symbol()) + .collect(); + assert_eq!(title.contains("F8"), kind.is_some()); + assert_eq!(title.contains("F9"), kind.is_some()); + } + } + } +} + +#[test] +fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() { + let root = std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("watch-state.toml"), + "version = 1\nwatched = ['jNQXAC9IVRw']\n", + ) + .unwrap(); + let category_channels = vec![config::normalize_channel("@creator").unwrap()]; + let config = config::Config { + categories: [("Category".into(), category_channels.clone())].into(), + ..Default::default() + }; + let path = root.join("config.toml"); + let mut app = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + video_state::State::open(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Watched","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Unwatched","uploader_id":"@creator"},{"id":"l-GaaivVJCE","title":"Outside category","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..3 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::Saved); + } + app.views[2].loaded = true; + app.switch_view(2); + app.browse_category_collection(CollectionKind::Saved); + app.preview = Some(PreviewMode::Description); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE)); + let removal = app.collection_removal.as_ref().unwrap(); + assert!(matches!(removal.kind, CollectionKind::Saved)); + assert_eq!(removal.category.as_deref(), Some("Category")); + assert_eq!(removal.filter, None); + assert_eq!(removal.ids, ["jNQXAC9IVRw", "rQ_J9WH6CGk"]); + let prompt = removal.text.clone(); + app.apply_config(config::Config { + categories: [( + "Category".into(), + vec![ + config::normalize_channel("@creator").unwrap(), + config::normalize_channel("@other").unwrap(), + ], + )] + .into(), + ..Default::default() + }); + assert_eq!(app.views[app.view_index].videos.len(), 3); + assert_eq!(app.collection_removal.as_ref().unwrap().text, prompt); + app.browse_category_collection(CollectionKind::WatchLater); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); + assert!( + app.video_state + .contains(CollectionKind::Saved, "l-GaaivVJCE") + ); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 3); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + app.apply_config(config::Config { + categories: [("Category".into(), category_channels)].into(), + ..Default::default() + }); + app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].videos.len(), 1); + app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + let removal = app.collection_removal.as_ref().unwrap(); + assert!(matches!(removal.kind, CollectionKind::WatchLater)); + assert_eq!(removal.filter, Some(WatchFilter::Watched)); + assert_eq!(removal.ids, ["jNQXAC9IVRw"]); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + assert!(app.views[app.view_index].videos.is_empty()); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + app.switch_view(0); + app.browse_collection(CollectionKind::WatchLater); + app.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].watch_filter, + WatchFilter::Unwatched + ); + assert_eq!(app.views[app.view_index].videos.len(), 2); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + assert!(app.collection_removal.as_ref().unwrap().category.is_none()); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!( + app.video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + app.browse_collection(CollectionKind::Saved); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!(app.video_state.videos(CollectionKind::Saved).is_empty()); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + drop(app); + let restarted = video_state::State::open(&path).unwrap(); + assert!(restarted.videos(CollectionKind::Saved).is_empty()); + assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); + assert!(restarted.is_watched("jNQXAC9IVRw")); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn individual_watch_later_removal_keeps_siblings_saved_and_watched() { + let root = std::env::temp_dir().join(format!( + "tubular-single-watch-removal-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("watch-state.toml"), + "version = 1\nwatched = ['jNQXAC9IVRw']\n", + ) + .unwrap(); + let mut app = App::with_state( + Options::parse_from(["tubular"]), + root.join("config.toml"), + config::Config::default(), + video_state::State::open(&root.join("config.toml")).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::Saved); + app.views[0].selection.select(Some(1)); + app.save_collection(CollectionKind::WatchLater); + app.browse_collection(CollectionKind::WatchLater); + app.views[app.view_index].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!( + !app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "rQ_J9WH6CGk") + ); + assert!( + app.video_state + .contains(CollectionKind::Saved, "jNQXAC9IVRw") + ); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + assert!(app.collection_removal.is_none()); + app.views[app.view_index].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!( + app.video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + assert_eq!(app.preview, None); + app.switch_view(0); + app.views[0].selection.select(Some(1)); + app.save_collection(CollectionKind::WatchLater); + app.browse_collection(CollectionKind::WatchLater); + app.preview = Some(PreviewMode::Description); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!( + app.video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + assert_eq!(app.preview, None); + drop(app); + let restarted = video_state::State::open(&root.join("config.toml")).unwrap(); + assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); + assert!(restarted.contains(CollectionKind::Saved, "jNQXAC9IVRw")); + assert!(restarted.is_watched("jNQXAC9IVRw")); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn manual_status_updates_filtered_lists_and_closes_removed_previews() { + let mut app = app(&["tubular"]); + let document = "[keys]\nvideo.status.watching = 'f6'\nvideo.status.watched = 'f7'\n" + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + } + app.browse_collection(CollectionKind::WatchLater); + app.filter_watch_later(); + app.views[app.view_index].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk"); + assert_eq!(app.preview, None); + app.filter_watch_later(); + assert_eq!(app.views[app.view_index].videos[0].id, "jNQXAC9IVRw"); + app.preview = Some(PreviewMode::Description); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + assert!(!app.video_state.is_watched("jNQXAC9IVRw")); + assert!(app.views[app.view_index].videos.is_empty()); + assert_eq!(app.views[app.view_index].selection.selected(), None); + assert_eq!(app.preview, None); + assert!( + app.video_state + .contains(CollectionKind::Watching, "jNQXAC9IVRw") + ); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + app.browse_collection(CollectionKind::Watching); + app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); + assert!(app.views[app.view_index].videos.is_empty()); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); +} + +#[test] +fn watch_later_tracks_current_tags_filters_and_confirms_only_snapshot_removal() { + let root = std::env::temp_dir().join(format!( + "tubular-watch-ui-regression-{}", + std::process::id() + )); + let path = root.join("config.toml"); + let _ = std::fs::remove_dir_all(&root); + let state = video_state::State::open(&path).unwrap(); + let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let config = config::Config { + channels: [("@creator".into(), channel.clone())].into(), + categories: [("Category".into(), vec![channel.clone()])].into(), + ..Default::default() + }; + let mut app = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + state, + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::WatchLater); + app.views[0].selection.select(Some(1)); + app.save_collection(CollectionKind::WatchLater); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + app.views[0].selection.select(None); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let title = buffer + .content + .iter() + .position(|cell| cell.symbol() == "M" && cell.fg == Color::Magenta); + assert!(title.is_some(), "saved video titles use a distinct color"); + app.views[0].selection.select(Some(1)); + + // An empty loaded category avoids extraction while opening its derived offline list. + app.views[2].loaded = true; + app.switch_view(2); + app.browse_category_collection(CollectionKind::WatchLater); + let category_source = ViewSource::WatchLater(Some("Category".into())); + assert_eq!(app.views[app.view_index].source, category_source); + assert_eq!(app.views[app.view_index].videos.len(), 1); + app.browse_category_collection(CollectionKind::WatchLater); + assert_eq!(app.views[app.view_index].source, category_source); + assert!(app.job.is_none()); + + app.filter_watch_later(); + assert_eq!( + app.views[app.view_index].watch_filter, + WatchFilter::Unwatched + ); + assert_eq!(app.views[app.view_index].videos.len(), 1); + app.filter_watch_later(); + assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::Watched); + assert!(app.views[app.view_index].videos.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + assert!(app.collection_removal.is_none()); + app.filter_watch_later(); + + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + let history = app.view_history.clone(); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + app.paste("y"); + assert!(app.collection_removal.is_some()); + assert!(app.player_launch.is_none()); + assert_eq!(app.view_history, history); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + assert!(app.collection_removal.is_none()); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 2); + + app.apply_config(config::Config { + channels: [("@creator".into(), channel.clone())].into(), + categories: [("Category".into(), vec![])].into(), + ..Default::default() + }); + assert_eq!(app.views[app.view_index].source, category_source); + assert!(app.views[app.view_index].videos.is_empty()); + app.apply_config(config::Config { + channels: [("@creator".into(), channel.clone())].into(), + categories: [("Category".into(), vec![channel])].into(), + ..Default::default() + }); + assert_eq!( + app.views[app.view_index].videos.len(), + 1, + "mapped handles follow canonical membership" + ); + app.apply_config(config::Config { + categories: [( + "Category".into(), + vec![config::normalize_channel("@creator").unwrap()], + )] + .into(), + ..Default::default() + }); + assert_eq!( + app.views[app.view_index].videos.len(), + 1, + "unmapped handles remain usable" + ); + + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + app.apply_config(config::Config { + categories: [( + "Category".into(), + vec![ + config::normalize_channel("@creator").unwrap(), + config::normalize_channel("@other").unwrap(), + ], + )] + .into(), + ..Default::default() + }); + assert_eq!(app.views[app.view_index].videos.len(), 2); + assert_eq!( + app.collection_removal.as_ref().unwrap().ids, + ["jNQXAC9IVRw"] + ); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::WatchLater).len(), 1); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "rQ_J9WH6CGk") + ); + assert_eq!(app.views[app.view_index].videos.len(), 1); + + app.switch_view(0); + app.browse_collection(CollectionKind::WatchLater); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(None) + ); + app.back_view(); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + app.forward_view(); + drop(app); + let mut restarted = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config::Config::default(), + video_state::State::open(&path).unwrap(), + ); + restarted.browse_collection(CollectionKind::WatchLater); + assert!(restarted.job.is_none()); + assert!(restarted.views[restarted.view_index].loaded); + assert_eq!( + restarted.views[restarted.view_index].videos[0].title, + "Other creator" + ); + restarted.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + restarted.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!( + restarted + .video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + drop(restarted); + assert!( + video_state::State::open(&path) + .unwrap() + .videos(CollectionKind::WatchLater) + .is_empty() + ); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn saved_views_follow_current_categories_and_restore_offline_history() { + let root = std::env::temp_dir().join(format!("tubular-saved-ui-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("config.toml"); + std::fs::write( + root.join("watch-state.toml"), + "version = 1\nwatched = [\"jNQXAC9IVRw\"]\n", + ) + .unwrap(); + let channel = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let other = config::normalize_channel("@other").unwrap(); + let config = config::Config { + channels: [("@creator".into(), channel.clone())].into(), + categories: [ + ("Category".into(), vec![channel.clone(), other.clone()]), + ("Overlap".into(), vec![channel]), + ] + .into(), + ..Default::default() + }; + let mut app = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + video_state::State::open(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Mapped creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other creator","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + } + app.views[2].loaded = true; + app.switch_view(2); + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); + let saved_category = ViewSource::SavedVideos(Some("Category".into())); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!(app.views[app.view_index].videos.len(), 2); + app.views[app.view_index].selection.select(Some(1)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Category".into())) + ); + app.filter_watch_later(); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!(app.views[app.view_index].title.contains("Unwatched")); + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!( + app.views[app.view_index].videos.len(), + 2, + "saved lists ignore watch filters" + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + let mut config = config::Config { + channels: app.config.channels.clone(), + categories: app.config.categories.clone(), + ..Default::default() + }; + config.categories.insert("Category".into(), vec![other]); + app.apply_config(config); + assert_eq!(app.views[app.view_index].source, saved_category); + assert_eq!(app.views[app.view_index].videos[0].id, "rQ_J9WH6CGk"); + assert_eq!(app.views[app.view_index].selection.selected(), Some(0)); + let watch = app + .views + .iter() + .find(|view| matches!(view.source, ViewSource::WatchLater(Some(_)))) + .unwrap(); + assert_eq!(watch.watch_filter, WatchFilter::Unwatched); + assert!(watch.title.contains("Unwatched")); + app.switch_view(0); + app.key(KeyEvent::new(KeyCode::Char('V'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert_eq!( + app.views[app.view_index].videos.len(), + 2, + "overlapping categories do not duplicate videos" + ); + app.views[app.view_index].selection.select(Some(1)); + app.back_view(); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + app.back_view(); + assert_eq!(app.views[app.view_index].source, saved_category); + app.forward_view(); + app.forward_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert!(app.job.is_none()); + app.views[app.view_index].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert!( + app.preview.is_none(), + "removing the selected row dismisses its preview" + ); + assert_eq!(app.views[app.view_index].videos.len(), 1); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + assert!(app.video_state.is_watched("jNQXAC9IVRw")); + let config = std::mem::take(&mut app.config); + drop(app); + let mut restarted = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config, + video_state::State::open(&path).unwrap(), + ); + restarted.browse_collection(CollectionKind::Saved); + let view = &restarted.views[restarted.view_index]; + assert!(view.loaded && !view.has_more); + assert_eq!(view.videos.len(), 1); + assert_eq!(view.videos[0].title, "Other creator"); + assert!(restarted.job.is_none()); + assert!(restarted.video_state.is_watched("jNQXAC9IVRw")); + assert_eq!( + restarted + .video_state + .videos(CollectionKind::WatchLater) + .len(), + 2 + ); + drop(restarted); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn collection_shortcuts_separate_global_and_category_scopes_in_every_video_context() { + for custom in [false, true] { + let root = std::env::temp_dir().join(format!( + "tubular-collection-scopes-{}-{custom}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write( + root.join("watch-state.toml"), + "version = 1\nwatched = ['l-GaaivVJCE']\n", + ) + .unwrap(); + let path = root.join("config.toml"); + let mut app = App::with_state( + Options::parse_from(["tubular"]), + path.clone(), + config::Config::default(), + video_state::State::open(&path).unwrap(), + ); + let bindings = if custom { + let document = "[keys]\nview.watch_later.browse = 'f5'\nview.saved.browse = 'f6'\nview.watch_later.category = 'f7'\nview.saved.category = 'f8'\n" + .parse::().unwrap(); + keys::Bindings::from_toml(document.get("keys")).unwrap() + } else { + keys::Bindings::default() + }; + app.apply_config(config::Config { + categories: [( + "Category".into(), + vec![config::normalize_channel("@creator").unwrap()], + )] + .into(), + keys: bindings, + ..Default::default() + }); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Category","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Other","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Watched category","uploader_id":"@creator"}]}"#, + ).unwrap().videos; + for view in &mut app.views { + view.loaded = true; + } + for row in 0..3 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + app.save_collection(CollectionKind::Saved); + } + let key = |function, character, modifiers| { + KeyEvent::new( + if custom { + KeyCode::F(function) + } else { + KeyCode::Char(character) + }, + if custom { + KeyModifiers::NONE + } else { + modifiers + }, + ) + }; + let global_watch = key(5, 'W', KeyModifiers::SHIFT); + let global_saved = key(6, 'V', KeyModifiers::SHIFT); + let category_watch = key(7, 'w', KeyModifiers::ALT); + let category_saved = key(8, 'v', KeyModifiers::ALT); + for (selector, expected) in [ + ( + global_watch, + ViewSource::WatchLater(Some("Category".into())), + ), + ( + global_saved, + ViewSource::SavedVideos(Some("Category".into())), + ), + ] { + app.switch_view(0); + let history = app.view_history.clone(); + app.key(selector); + let browser = app.browser.as_ref().unwrap(); + assert!(matches!( + browser.choices[0].1, + ViewSource::WatchLater(None) | ViewSource::SavedVideos(None) + )); + app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(app.config.pinned_categories.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + for c in "zzzz".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.browser.as_ref().unwrap().matches.is_empty()); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + assert_eq!(app.view_history, history); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + for c in "category".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert_eq!(app.browser.as_ref().unwrap().matches.len(), 1); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].source, expected); + assert_eq!(app.views[app.view_index].videos.len(), 2); + assert!(app.browser.is_none()); + } + // Seed a cached combined view with a different filter. + choose_combined(&mut app, global_watch); + assert_eq!(app.views[app.view_index].watch_filter, WatchFilter::All); + app.switch_view(2); + app.key(category_watch); + app.filter_watch_later(); + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + choose_combined(&mut app, global_watch); + let view = &app.views[app.view_index]; + assert_eq!(view.source, ViewSource::WatchLater(None)); + assert_eq!(view.watch_filter, WatchFilter::Unwatched); + assert_eq!( + view.videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["jNQXAC9IVRw", "rQ_J9WH6CGk"] + ); + assert_eq!(app.preview, None); + let history = app.view_history.clone(); + app.preview = preview; + choose_combined(&mut app, global_watch); + assert_eq!(app.preview, None); + assert_eq!(app.view_history, history); + app.back_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Category".into())) + ); + assert_eq!( + app.views[app.view_index].watch_filter, + WatchFilter::Unwatched + ); + assert_eq!(app.views[app.view_index].videos.len(), 1); + } + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + app.key(category_saved); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(Some("Category".into())) + ); + assert_eq!(app.preview, None); + app.preview = preview; + choose_combined(&mut app, global_saved); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert_eq!(app.views[app.view_index].videos.len(), 3); + assert_eq!(app.preview, None); + app.back_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(Some("Category".into())) + ); + app.preview = preview; + app.key(category_watch); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Category".into())) + ); + assert_eq!( + app.views[app.view_index].watch_filter, + WatchFilter::Unwatched + ); + assert_eq!(app.preview, None); + } + app.switch_view(0); + choose_combined(&mut app, global_watch); + assert_eq!( + app.views[app.view_index].watch_filter, + WatchFilter::Unwatched + ); + let mut channel = View::new( + "Creator".into(), + ViewSource::Channel(config::normalize_channel("@creator").unwrap()), + ); + channel.loaded = true; + app.views.push(channel); + for index in [0, 1, app.views.len() - 1] { + app.switch_view(index); + for key in [category_watch, category_saved] { + let history = app.view_history.clone(); + app.key(key); + assert_eq!(app.view_index, index); + assert_eq!(app.view_history, history); + } + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + for key in [global_watch, global_saved] { + app.preview = preview; + choose_combined(&mut app, key); + assert!(matches!( + app.views[app.view_index].source, + ViewSource::WatchLater(None) | ViewSource::SavedVideos(None) + )); + assert_eq!(app.preview, None); + app.back_view(); + assert_eq!(app.view_index, index); + } + } + } + for key in [global_watch, global_saved] { + choose_combined(&mut app, key); + let source = app.views[app.view_index].source.clone(); + for category_key in [category_watch, category_saved] { + app.key(category_key); + assert_eq!(app.views[app.view_index].source, source); + } + } + assert!(app.job.is_none()); + drop(app); + std::fs::remove_dir_all(root).unwrap(); + } +} + +#[test] +fn collection_shortcuts_leave_text_entry_bindings_unchanged() { + let mut app = app(&["tubular"]); + let type_keys = |app: &mut App| { + for character in ['W', 'V', 'w', 'v'] { + app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE)); + } + for character in ['w', 'v'] { + app.key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)); + } + assert_eq!(app.view_index, 0); + assert!(app.view_history.is_empty()); + }; + app.mode = Mode::Insert; + type_keys(&mut app); + assert_eq!(app.views[0].query, "WVwv"); + app.mode = Mode::Normal; + for kind in [BrowserKind::Categories, BrowserKind::Channels] { + app.open_browser(kind); + app.browser.as_mut().unwrap().mode = Mode::Insert; + type_keys(&mut app); + assert_eq!(app.browser.as_ref().unwrap().query, "WVwv"); + app.browser = None; + } + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@creator".into(), + channel: "creator".into(), + }, + "Creator".into(), + BTreeMap::new(), + )); + app.category_dialog.as_mut().unwrap().mode = Mode::Insert; + type_keys(&mut app); + assert_eq!(app.category_dialog.as_ref().unwrap().query, "WVwv"); + app.category_dialog.as_mut().unwrap().name = Some(String::new()); + type_keys(&mut app); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("WVwv") + ); +} + +#[test] +fn saved_actions_use_configured_keys_in_each_video_context_and_remove_only_selection() { + let mut app = app(&["tubular"]); + let document = "[keys]\nvideo.saved.save = 'f6'\nview.saved.browse = 'f7'\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n" + .parse::().unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@other"},{"id":"l-GaaivVJCE","title":"Missing creator"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(1)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE)); + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = preview; + if !app + .video_state + .contains(CollectionKind::Saved, "jNQXAC9IVRw") + { + app.key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)); + assert!( + !app.video_state + .contains(CollectionKind::Saved, "jNQXAC9IVRw") + ); + } + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + let status = app.status.clone(); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + assert_eq!(app.preview, preview); + assert_eq!(app.status, status); + } + for preview in [ + None, + Some(PreviewMode::Info), + Some(PreviewMode::Description), + ] { + app.preview = None; + app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let row = app.views[app.view_index] + .videos + .iter() + .position(|video| video.id == "jNQXAC9IVRw") + .unwrap(); + app.views[app.view_index].selection.select(Some(row)); + app.preview = preview; + assert!(matches!( + app.current_collection(), + Some(CollectionKind::Saved) + )); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 1); + assert!( + app.video_state + .contains(CollectionKind::Saved, "rQ_J9WH6CGk") + ); + assert!( + app.video_state + .contains(CollectionKind::WatchLater, "jNQXAC9IVRw") + ); + assert_eq!(app.preview, None); + app.switch_view(0); + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + } + app.preview = None; + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + app.views[0].selection.select(Some(2)); + app.key(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE)); + assert!(app.status.contains("channel identity")); + assert!( + !app.video_state + .contains(CollectionKind::Saved, "l-GaaivVJCE") + ); + app.views[0].selection.select(None); + let status = app.status.clone(); + app.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)); + assert_eq!(app.status, status); + app.key(KeyEvent::new(KeyCode::F(7), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::SavedVideos(None) + ); + assert!(app.job.is_none()); + assert!(app.key_hints.footer(Context::Normal).contains("F6/F7")); + assert!(app.key_hints.saved_videos.contains("F8")); + app.key(KeyEvent::new(KeyCode::Char('W'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::SHIFT)); + assert!(app.collection_removal.is_none()); + app.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)); + assert!(app.collection_removal.is_some()); + app.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); + assert!( + app.video_state + .videos(CollectionKind::WatchLater) + .is_empty() + ); + assert_eq!(app.video_state.videos(CollectionKind::Saved).len(), 2); + assert!(!app.video_state.is_watched("jNQXAC9IVRw")); +} + +#[test] +fn saved_green_precedes_watch_later_magenta_without_changing_creator_or_selection_colors() { + let mut app = app(&["tubular"]); + app.config.categories.insert( + "Tagged".into(), + vec![config::normalize_channel("@creator").unwrap()], + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Saved title","channel":"Creator","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Later title","channel":"Other","uploader_id":"@other"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + for row in 0..2 { + app.views[0].selection.select(Some(row)); + app.save_collection(CollectionKind::WatchLater); + } + app.views[0].selection.select(Some(0)); + app.save_collection(CollectionKind::Saved); + app.views[0].selection.select(None); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "S" && cell.fg == Color::Green) + ); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "L" && cell.fg == Color::Magenta) + ); + assert!( + buffer + .content + .iter() + .any(|cell| cell.symbol() == "C" && cell.fg == Color::Cyan) + ); + app.views[0].selection.select(Some(0)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.symbol() == "S" && cell.fg == Color::Black && cell.bg == Color::Cyan) + ); + app.browse_collection(CollectionKind::Saved); + app.key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); + app.switch_view(0); + app.views[0].selection.select(None); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.symbol() == "S" && cell.fg == Color::Magenta) + ); +} + +#[test] +fn paste_targets_only_active_inputs_and_refilters_without_executing_shortcuts() { + let mut app = app(&["tubular"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("Haru 日本語\r\ns\t\x1b[31m"); + assert_eq!(app.views[0].query, "Haru 日本語 s [31m"); + assert_eq!(app.mode, Mode::Insert); + assert!(app.views[0].submitted_query.is_empty()); + assert!(app.job.is_none(), "paste must not submit a search"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.paste("i\nq"); + assert_eq!(app.views[0].query, "Haru 日本語 s [31m"); + assert_eq!(app.mode, Mode::Normal); + + app.config.categories = [("Alpha".into(), vec![]), ("日本語".into(), vec![])].into(); + app.config.channels = [ + ( + "@Alpha".into(), + config::normalize_channel("@Alpha").unwrap(), + ), + ( + "@日本語".into(), + config::normalize_channel("@日本語").unwrap(), + ), + ] + .into(); + for kind in [BrowserKind::Categories, BrowserKind::Channels] { + app.open_browser(kind); + app.paste("Alpha"); + assert!(app.browser.as_ref().unwrap().query.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("日本語"); + let browser = app.browser.as_ref().unwrap(); + assert_eq!(browser.matches, [1]); + assert_eq!(browser.selection.selected(), Some(0)); + assert!(browser.choices[browser.matches[0]].0.contains("日本語")); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + } + + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@a".into(), + channel: String::new(), + }, + "Creator".into(), + [("Alpha".into(), true), ("日本語".into(), false)].into(), + )); + app.paste("i\n "); + assert!(app.category_dialog.as_ref().unwrap().tags["Alpha"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste("日本語"); + assert_eq!(app.category_dialog.as_ref().unwrap().matches, [1]); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().tags["日本語"]); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.paste("New\nTag\t?"); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("New Tag ?") + ); + assert!( + app.help_popup.is_none(), + "pasted ? must remain literal text" + ); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().tags["New Tag ?"]); + assert!( + !app.config.categories.contains_key("New Tag ?"), + "paste must not save tags" + ); + app.key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE)); + app.paste("n\nq"); + assert!(app.category_dialog.as_ref().unwrap().name.is_none()); + assert!( + app.help_popup.is_some(), + "paste must not close help or operate on its dialog" + ); +} + +fn loaded_channel_views(app: &mut App) { + let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; + for (label, identity) in app.config.saved_channels() { + let mut view = View::new(label, ViewSource::Channel(identity)); + view.videos = feed_fixture(videos).unwrap().videos; + view.loaded = true; + view.next_start = 41; + view.selection.select(Some(1)); + app.views.push(view); + } +} + +fn browse_channel(app: &mut App, filter: &str) { + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + for c in filter.chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); +} + +#[test] +fn video_channel_colors_follow_category_membership_and_preserve_selection_contrast() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + let tagged_id = "UC4QobU6STFB0P71PMvOGN5A"; + let registered_id = "UCaYhcUwRBNscFNUKTjgPFiA"; + app.config.channels.insert( + "@mapped".into(), + config::normalize_channel(tagged_id).unwrap(), + ); + app.config.channels.insert( + "@registered".into(), + config::normalize_channel(registered_id).unwrap(), + ); + app.config.categories.insert( + "Favorites".into(), + vec![ + config::normalize_channel(tagged_id).unwrap(), + config::normalize_channel("@direct").unwrap(), + ], + ); + let cases = [ + ("TaggedID", Some(tagged_id), None, true), + ("TaggedHandle", None, Some("@direct"), true), + ("MappedHandle", None, Some("@mapped"), true), + ( + "Registered", + Some(registered_id), + Some("@registered"), + false, + ), + ("TaggedID", Some(registered_id), None, false), + ("Unknown", None, None, false), + ]; + app.views[0].videos = youtube::search_results( + &serde_json::json!({ + "entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({ + "id": "jNQXAC9IVRw", "title": "Video title", "channel": channel, + "channel_id": id, "uploader_id": handle, "upload_date": "20261011" + })).collect::>() + }) + .to_string(), + ) + .unwrap() + .videos; + app.views[0].selection.select(None); + let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap(); + for cleared in [false, true] { + if cleared { + app.config.categories.clear(); + } + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + // One tab row, three search rows, then the table border and header. + for (index, (label, _, _, tagged)) in cases.iter().enumerate() { + let y = 6 + index as u16; + let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect(); + let x = row.find(label).expect("channel label is visible") as u16; + assert_eq!( + buffer[(x, y)].fg, + if *tagged && !cleared { + Color::Cyan + } else { + Color::Reset + }, + "{label} in row {index}, cleared={cleared}" + ); + assert_eq!(buffer[(3, y)].fg, Color::Reset, "title stays uncolored"); + assert_eq!(buffer[(108, y)].fg, Color::Reset, "date stays uncolored"); + } + if !cleared { + app.views[0].selection.select(Some(0)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let row: String = (0..120).map(|x| buffer[(x, 6)].symbol()).collect(); + let x = row.find("TaggedID").unwrap() as u16; + assert_eq!(buffer[(x, 6)].fg, Color::Black); + assert_eq!(buffer[(x, 6)].bg, Color::Cyan); + app.views[0].selection.select(None); + } + } +} + +#[test] +fn remapped_preview_and_input_actions_survive_key_only_config_reload() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, + ).unwrap().videos; + // Invalid IDs avoid curl; full descriptions exercise the real preview without extraction. + for (index, id) in ["first", "second"].into_iter().enumerate() { + app.views[0].videos[index].id = id.into(); + app.remember_description(id.into(), "A long description paragraph.\n\n".repeat(40)); + } + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.views[0].next_start = 41; + let document = r#"[keys] +video.info.overview = "I" +video.info.hide = "x" +view.collection.remove.selected = [] +search.edit = "f2" +input.delete.word = "ctrl+backspace" +input.delete.line = "ctrl+l" +"# + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); + press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE); + assert_eq!(app.preview, Some(PreviewMode::Description)); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); + assert_eq!(app.description_scroll, 1); + press(&mut app, KeyCode::Char('I'), KeyModifiers::SHIFT); + assert_eq!(app.preview, Some(PreviewMode::Info)); + press(&mut app, KeyCode::Char('x'), KeyModifiers::NONE); + assert_eq!(app.preview, None); + assert!(app.description_job.is_none()); + let remap = "[keys]\nvideo.select.next = 'n'\n" + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(remap.get("keys")).unwrap(), + ..Default::default() + }); + assert_eq!(app.views[0].next_start, 41); + assert_eq!(app.views[0].videos[0].title, "First"); + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); + assert_eq!(app.views[0].selection.selected(), Some(0)); + press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + assert_eq!(app.mode, Mode::Normal); + press(&mut app, KeyCode::F(2), KeyModifiers::NONE); + for character in "red blue I d?".chars() { + press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); + } + assert_eq!(app.views[0].query, "red blue I d?"); + assert_eq!(app.preview, None); + press(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL); + assert_eq!(app.views[0].query, "red blue I "); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + assert_eq!(app.views[0].query, "red blue I "); + press(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL); + assert_eq!(app.views[0].query, ""); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.mode, Mode::Normal); +} + +#[test] +fn remapped_selector_and_tag_keys_keep_input_and_modal_navigation_separate() { + let mut app = app(&["tubular"]); + let document = r#"[keys] +selector.select.next = "l" +selector.edit = "f3" +selector.close = "z" +input.submit = "ctrl+e" +input.cancel = "ctrl+b" +tags.new = "f4" +tags.cancel = "z" +"# + .parse::() + .unwrap(); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + loaded_channel_views(&mut app); + let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); + app.open_browser(BrowserKind::Channels); + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + press(&mut app, KeyCode::F(3), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().query, "l"); + assert_eq!(app.browser.as_ref().unwrap().matches, [0]); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(app.browser.is_some()); + press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); + assert!(app.browser.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + assert_eq!(app.views[app.view_index].next_start, 41); + let history = app.view_history.clone(); + app.category_dialog = Some(CategoryDialog { + name: None, + ..CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Beta".into(), + channel: "beta".into(), + }, + "@Beta".into(), + BTreeMap::new(), + ) + }); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.view_history, history); + assert!(app.category_dialog.is_some()); + press(&mut app, KeyCode::F(4), KeyModifiers::NONE); + for character in "lI?".chars() { + press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); + } + press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); + assert_eq!( + app.category_dialog.as_ref().unwrap().tags.get("lI?"), + Some(&true) + ); + press(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); + assert!(app.category_dialog.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); +} + +#[test] +fn help_scrolls_without_navigating_and_closes_to_the_same_preview() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + browse_channel(&mut app, "Alpha"); + browse_channel(&mut app, "Beta"); + let history = app.view_history.clone(); + let selected = app.views[app.view_index].selection.selected(); + let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE)); + app.views[app.view_index].has_more = true; + app.views[app.view_index].prefetch_requested = true; + assert!(app.can_prefetch()); + press(&mut app, KeyCode::Char('?')); + assert!(!app.can_prefetch()); + app.preview = Some(PreviewMode::Info); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.help_popup.as_ref().unwrap().scroll, 1); + for code in [ + KeyCode::Enter, + KeyCode::Tab, + KeyCode::Char('-'), + KeyCode::Char('+'), + ] { + press(&mut app, code); + } + assert_eq!(app.view_history, history); + assert!(app.forward_history.is_empty()); + assert!(app.player_launch.is_none()); + assert_eq!(app.views[app.view_index].selection.selected(), selected); + for _ in 0..100 { + press(&mut app, KeyCode::PageDown); + } + let help = app.help_popup.as_ref().unwrap(); + assert_eq!(help.scroll, help.max_scroll); + let previous_max = help.max_scroll; + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.help_popup.as_ref().unwrap().scroll, previous_max - 1); + let mut larger = Terminal::new(TestBackend::new(120, 40)).unwrap(); + larger + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let help = app.help_popup.as_ref().unwrap(); + assert_eq!(help.scroll, help.max_scroll); + for _ in 0..100 { + press(&mut app, KeyCode::PageUp); + } + assert_eq!(app.help_popup.as_ref().unwrap().scroll, 0); + press(&mut app, KeyCode::Esc); + assert!(app.help_popup.is_none()); + assert_eq!(app.preview, Some(PreviewMode::Info)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + press(&mut app, KeyCode::Char('-')); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("alpha".into()) + ); + press(&mut app, KeyCode::Char('+')); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); +} + +#[test] +fn help_preserves_selector_state_and_question_mark_is_literal_in_every_input() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [("@Alpha".into(), "alpha".into())].into(), + ..Default::default() + }); + let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE)); + app.open_browser(BrowserKind::Channels); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_some()); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_none()); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_none()); + assert_eq!(app.browser.as_ref().unwrap().query, "?"); + assert!(app.browser.as_ref().unwrap().matches.is_empty()); + press(&mut app, KeyCode::Esc); + press(&mut app, KeyCode::Esc); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_none()); + assert_eq!(app.views[0].query, "?"); + press(&mut app, KeyCode::Esc); + app.category_dialog = Some(CategoryDialog { + name: Some(String::new()), + ..CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: "alpha".into(), + }, + "@Alpha".into(), + BTreeMap::new(), + ) + }); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_none()); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("?") + ); + press(&mut app, KeyCode::Esc); + press(&mut app, KeyCode::Char('?')); + assert!(app.help_popup.is_some()); + press(&mut app, KeyCode::Esc); + assert!(app.category_dialog.is_some()); + assert!(app.help_popup.is_none()); +} + +#[test] +fn plus_restores_forward_views_and_new_navigation_discards_the_forward_branch() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + for view in &mut app.views { + view.loaded = true; + } + browse_channel(&mut app, "Alpha"); + browse_channel(&mut app, "Beta"); + let press = |app: &mut App, c| { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + }; + press(&mut app, '-'); + press(&mut app, '-'); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + press(&mut app, '+'); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("alpha".into()) + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert_eq!(app.views[app.view_index].next_start, 41); + app.switch_view(app.view_index); // Refocusing does not discard forward history. + press(&mut app, '+'); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + press(&mut app, '+'); // End of history is a no-op. + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + press(&mut app, '-'); + app.switch_view(1); + press(&mut app, '+'); + assert_eq!(app.views[app.view_index].source, ViewSource::Feed); + app.mode = Mode::Insert; + press(&mut app, '+'); + assert_eq!(app.views[0].query, "+"); + assert_eq!(app.views[app.view_index].source, ViewSource::Feed); + assert!(app.job.is_none()); +} + +#[test] +fn forward_navigation_skips_unsubscribed_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + app.views[0].loaded = true; + browse_channel(&mut app, "Alpha"); + browse_channel(&mut app, "Beta"); + app.back_view(); + app.back_view(); + app.apply_config(config::Config { + channels: [("@Beta".into(), "beta".into())].into(), + ..Default::default() + }); + app.key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + assert_eq!(app.temporary_view, Some(ViewSource::Channel("beta".into()))); + assert!(app.job.is_none()); +} + +#[test] +fn escape_and_minus_restore_view_history_without_consuming_modal_escape() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [("Category".into(), Vec::new())].into(), + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + for view in &mut app.views { + view.loaded = true; + } + app.switch_view(2); + browse_channel(&mut app, "Alpha"); + browse_channel(&mut app, "Beta"); + app.switch_view(app.view_index); // Refocusing must not add a history entry. + app.preview = Some(PreviewMode::Info); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.preview.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + app.mode = Mode::Insert; + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.views[0].query, "-"); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + app.open_browser(BrowserKind::Channels); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.browser.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("alpha".into()) + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert_eq!(app.views[app.view_index].next_start, 41); + assert_eq!( + app.temporary_view, + Some(ViewSource::Channel("alpha".into())) + ); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Category".into()) + ); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + assert!(app.job.is_none()); +} + +#[test] +fn back_history_uses_source_identity_after_config_reorders_or_removes_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [("Earlier".into(), Vec::new()), ("Keep".into(), Vec::new())].into(), + channels: [("@Alpha".into(), "alpha".into())].into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + for view in &mut app.views { + view.loaded = true; + } + app.switch_view(3); // Keep initially follows Earlier. + browse_channel(&mut app, "Alpha"); + app.apply_config(config::Config { + categories: [("Keep".into(), Vec::new())].into(), + channels: [("@Alpha".into(), "alpha".into())].into(), + ..Default::default() + }); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Keep".into()) + ); + browse_channel(&mut app, "Alpha"); + app.apply_config(config::Config { + channels: [("@Alpha".into(), "alpha".into())].into(), + ..Default::default() + }); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + assert!(app.job.is_none()); +} + +#[test] +fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() { + let mut app = app(&["tubular"]); + let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + app.apply_config(config::Config { + channels: [("@Saved".into(), identity.into())].into(), + ..Default::default() + }); + loaded_channel_views(&mut app); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Saved"}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.preview = Some(PreviewMode::Description); + let press = |app: &mut App| { + app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT)); + }; + press(&mut app); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel(identity.into()) + ); + assert_eq!(app.views[app.view_index].selection.selected(), Some(1)); + assert_eq!(app.views[app.view_index].next_start, 41); + assert_eq!(app.views[app.view_index].videos[1].title, "Second"); + assert!(app.preview.is_none()); + assert!(app.job.is_none()); + let count = app.views.len(); + press(&mut app); + assert_eq!(app.views.len(), count); + app.switch_view(0); + app.views[0].videos[0].channel_id = None; + press(&mut app); // A handle-only row resolves through the saved mapping. + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel(identity.into()) + ); + app.switch_view(0); + app.views[0].videos[0].uploader_id = Some("@invalid/handle".into()); + press(&mut app); + assert_eq!(app.view_index, 0); + assert_eq!(app.views.len(), count); + assert!(app.job.is_none()); + app.views[0].videos[0].uploader_id = None; + press(&mut app); + assert_eq!(app.view_index, 0); + app.mode = Mode::Insert; + press(&mut app); + assert_eq!(app.views[0].query, "U"); + assert_eq!(app.view_index, 0); +} + +#[test] +fn unsaved_video_channels_survive_config_changes_without_subscribing() { + let mut app = app(&["tubular"]); + let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + let mut channel = View::new("@Unsaved".into(), ViewSource::Channel(identity.into())); + channel.loaded = true; + app.views.push(channel); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Unsaved"}]}"#, + ).unwrap().videos; + app.views[0].selection.select(Some(0)); + app.views[0].loaded = true; + app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel(identity.into()) + ); + assert!(app.config.subscriptions().is_empty()); + app.apply_config(config::Config { + categories: [("Unrelated".into(), Vec::new())].into(), + ..Default::default() + }); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel(identity.into()) + ); + assert!(app.views[app.view_index].loaded); + assert_eq!( + app.temporary_view, + Some(ViewSource::Channel(identity.into())) + ); + app.apply_config(config::Config { + channels: [("@Registered".into(), identity.into())].into(), + ..Default::default() + }); + assert_eq!(app.views[app.view_index].name, "@Registered"); + assert!(app.views[app.view_index].loaded); + app.apply_config(config::Config::default()); + assert_eq!(app.view_index, 0); + assert!( + app.views + .iter() + .all(|view| view.source != ViewSource::Channel(identity.into())) + ); +} + +#[test] +fn channels_filter_in_both_modes_without_pinning_or_running_modal_shortcuts() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [ + ("@jazz".into(), "jazz identity".into()), + ("@jokes".into(), "jokes identity".into()), + ] + .into(), + ..config::Config::default() + }); + loaded_channel_views(&mut app); + app.views[0].query = "untouched".into(); + app.views[0].loaded = true; + app.views[0].has_more = true; + app.preview = Some(PreviewMode::Info); + assert!(!app.can_prefetch()); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + assert!(app.preview.is_none()); + assert!(!app.can_prefetch()); + app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(app.config.pinned_categories.is_empty()); + assert!(app.browser.as_ref().unwrap().error.is_none()); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + for c in "jkp".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert_eq!(app.browser.as_ref().unwrap().query, "jkp"); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + assert!(app.browser.is_some()); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE)); + for (code, modifiers, row) in [ + (KeyCode::Char('j'), KeyModifiers::CONTROL, 1), + (KeyCode::Char('k'), KeyModifiers::CONTROL, 0), + (KeyCode::Down, KeyModifiers::NONE, 1), + (KeyCode::Up, KeyModifiers::NONE, 0), + ] { + app.key(KeyEvent::new(code, modifiers)); + assert_eq!( + app.browser.as_ref().unwrap().selection.selected(), + Some(row) + ); + } + assert_eq!(app.browser.as_ref().unwrap().query, "J"); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("jazz identity".into()) + ); + assert_eq!( + app.views[app.view_index].source.channels( + &app.config, + &app.feed_channels, + &app.views[app.view_index].group_channels + ), + ["jazz identity"] + ); + assert_eq!(app.views[0].query, "untouched"); + assert!(app.browser.is_none()); + assert!(app.job.is_none()); +} + +#[test] +fn channel_and_category_share_temporary_tab_and_cached_history() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + keys: Default::default(), + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + categories: [ + ("@Alpha".into(), vec!["beta".into()]), + ("Temporary".into(), vec![]), + ] + .into(), + pinned_categories: vec!["@Alpha".into()], + }); + loaded_channel_views(&mut app); + app.views[2].loaded = true; + app.switch_view(3); + browse_channel(&mut app, "Alpha"); + let alpha_source = ViewSource::Channel("alpha".into()); + assert_eq!(app.views[app.view_index].source, alpha_source); + assert_eq!( + app.views[app.view_index].source.channels( + &app.config, + &app.feed_channels, + &app.views[app.view_index].group_channels + ), + ["alpha"] + ); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + assert_eq!(app.views[app.view_index].source, alpha_source); + browse_channel(&mut app, "Beta"); + let visible: Vec<_> = app + .visible_views() + .map(|(_, view)| view.source.clone()) + .collect(); + assert_eq!( + visible, + [ + ViewSource::Search, + ViewSource::Feed, + ViewSource::Category("@Alpha".into()), + ViewSource::Channel("beta".into()) + ] + ); + assert!(app.views.iter().any(|view| view.source == alpha_source)); + browse_channel(&mut app, "Alpha"); + let view = &app.views[app.view_index]; + assert_eq!(view.source, alpha_source); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.next_start, 41); + assert_eq!(view.videos.len(), 2); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE)); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Temporary".into()) + ); + assert!( + !app.visible_views() + .any(|(_, view)| matches!(view.source, ViewSource::Channel(_))) + ); + browse_channel(&mut app, "Alpha"); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].source, alpha_source); + assert!(app.job.is_none()); +} + +#[test] +fn channel_refresh_matches_identities_relabels_caches_and_removes_unsubscribed_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + categories: [("@Alpha".into(), vec!["beta".into()])].into(), + ..config::Config::default() + }); + loaded_channel_views(&mut app); + browse_channel(&mut app, "Alpha"); + app.views[app.view_index].has_more = true; + app.views[app.view_index].feed_requests = + app.views[app.view_index].feed_cursor.plan(1).unwrap(); + app.views[app.view_index].prefetch_requested = true; + app.views[app.view_index].page_error = Some("kept".into()); + app.apply_config(config::Config { + keys: Default::default(), + channels: [ + ("@Renamed".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + categories: [ + ("New tag".into(), vec!["alpha".into()]), + ("@Alpha".into(), vec![]), + ] + .into(), + pinned_categories: vec!["@Alpha".into()], + }); + let view = &app.views[app.view_index]; + assert_eq!(view.source, ViewSource::Channel("alpha".into())); + assert_eq!(view.name, "@Renamed"); + assert_eq!(view.title, " @Renamed "); + assert!(view.loaded && view.has_more); + assert_eq!(view.next_start, 41); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.feed_requests.len(), 1); + assert!(view.prefetch_requested); + assert_eq!(view.page_error.as_deref(), Some("kept")); + let beta = app + .views + .iter() + .find(|view| view.source == ViewSource::Channel("beta".into())) + .unwrap(); + assert_eq!(beta.next_start, 41); + assert_eq!(beta.selection.selected(), Some(1)); + app.apply_config(config::Config { + keys: Default::default(), + channels: [("@Beta".into(), "beta".into())].into(), + categories: [("@Alpha".into(), vec!["beta".into()])].into(), + pinned_categories: vec!["@Alpha".into()], + }); + assert_eq!(app.views[app.view_index].source, ViewSource::Search); + assert!(app.temporary_view.is_none()); + assert!( + !app.views + .iter() + .any(|view| view.source == ViewSource::Channel("alpha".into())) + ); + browse_channel(&mut app, "Beta"); + assert_eq!(app.views[app.view_index].next_start, 41); + app.apply_config(config::Config::default()); + assert_eq!(app.view_index, 0); + assert_eq!(app.views.len(), 2); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + for code in [KeyCode::Down, KeyCode::Enter, KeyCode::Char('p')] { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + } + assert!(app.browser.as_ref().unwrap().choices.is_empty()); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.browser.is_none()); + assert!(app.job.is_none()); +} + +#[test] +fn channel_labels_cannot_alias_builtin_or_category_sources() { + let mut app = app(&["tubular"]); + // Raw fixture identities exercise equal labels without invoking an extractor. + app.apply_config(config::Config { + categories: [ + ("Feed".into(), vec!["Feed".into(), "Search".into()]), + ("Search".into(), vec![]), + ] + .into(), + pinned_categories: vec!["Feed".into(), "Search".into()], + ..config::Config::default() + }); + loaded_channel_views(&mut app); + for label in ["Feed", "Search"] { + browse_channel(&mut app, label); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel(label.into()) + ); + assert_eq!( + app.views[app.view_index].source.channels( + &app.config, + &app.feed_channels, + &app.views[app.view_index].group_channels + ), + [label] + ); + assert_eq!( + app.visible_views() + .filter(|(_, view)| view.name == label) + .count(), + 3 + ); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + } + assert!(app.job.is_none()); +} + +#[test] +fn channel_selector_renders_plain_labels_modes_empty_states_and_filter_cursor() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + channels: [("@Alpha".into(), "alpha".into())].into(), + ..config::Config::default() + }); + let mut terminal = Terminal::new(TestBackend::new(180, 40)).unwrap(); + let text = |terminal: &Terminal| -> String { + terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect() + }; + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let rendered = text(&terminal); + assert!(rendered.contains("@Alpha")); + assert!(!rendered.contains("[p]") && !rendered.contains("[ ]")); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let popup = preview_area(Rect::new(0, 0, 180, 40)); + assert_eq!( + terminal.get_cursor_position().unwrap(), + (popup.x + 3, popup.y + 2).into() + ); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + app.apply_config(config::Config::default()); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!(app.job.is_none()); +} +#[test] +fn filtered_tagging_saves_visible_changes_without_losing_hidden_memberships() { + use ratatui::{Terminal, backend::TestBackend}; + + for bulk in [false, true] { + let path = std::env::temp_dir().join(format!( + "tubular-filtered-tags-{}-{bulk}.toml", + std::process::id() + )); + let original = "[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@a', '@b']\nGamma = []\nZebra = ['@a', '@b']\n"; + std::fs::write(&path, original).unwrap(); + let mut app = App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Creator video","channel":"Creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@a"}]}"# + ).unwrap().videos; + app.views[0].selection.select(Some(0)); + if bulk { + app.view_index = app + .views + .iter() + .position(|view| view.name == "Alpha") + .unwrap(); + } + app.views[app.view_index].loaded = true; + let press = |app: &mut App, code, modifiers| { + app.key(KeyEvent::new(code, modifiers)); + }; + press( + &mut app, + KeyCode::Char(if bulk { 'S' } else { 's' }), + KeyModifiers::NONE, + ); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + for c in "missing".chars() { + press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); + } + let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("No matching tags.")); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + for c in "GA".chars() { + press(&mut app, KeyCode::Char(c), KeyModifiers::SHIFT); + } + press(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(app.category_dialog.is_some(), "filter Enter must not save"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let text: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(text.contains("[x] Gamma")); + assert!(!text.contains("[x] Alpha") && !text.contains("[x] Zebra")); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(app.category_dialog.is_none()); + let saved = config::load(&path).unwrap(); + let a = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let b = config::normalize_channel("UCaYhcUwRBNscFNUKTjgPFiA").unwrap(); + assert_eq!(saved.categories["Alpha"], [a.clone(), b.clone()]); + assert_eq!(saved.categories["Zebra"], [a.clone(), b.clone()]); + assert_eq!( + saved.categories["Gamma"], + if bulk { vec![a, b] } else { vec![a] } + ); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn tag_filter_preserves_selection_and_stages_new_tags_outside_the_filter() { + let mut app = app(&["tubular"]); + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@a".into(), + channel: String::new(), + }, + "Creator".into(), + [ + ("Alpha".into(), false), + ("Gamma".into(), true), + ("Zebra".into(), false), + ] + .into(), + )); + let press = |app: &mut App, code, modifiers| { + app.key(KeyEvent::new(code, modifiers)); + }; + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('a'), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + assert!(app.category_dialog.as_ref().unwrap().tags["Zebra"]); + assert!(!app.category_dialog.as_ref().unwrap().tags["Alpha"]); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + for c in "gamma".chars() { + press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); + } + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE); + for c in "New".chars() { + press(&mut app, KeyCode::Char(c), KeyModifiers::NONE); + } + press(&mut app, KeyCode::Enter, KeyModifiers::NONE); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(dialog.tags["New"]); + assert!(dialog.query.is_empty(), "new tag must be visible"); + press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE); + assert!( + !app.category_dialog.as_ref().unwrap().tags["New"], + "new tag is selected" + ); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert!(app.category_dialog.is_none()); + assert!( + app.config.categories.is_empty(), + "cancel discards staged changes" + ); +} + +#[test] +fn bulk_picker_uses_all_saved_channels_and_commits_or_cancels_the_whole_group() { + let path = + std::env::temp_dir().join(format!("tubular-bulk-picker-{}.toml", std::process::id())); + let original = "[settings]\npinned_categories = ['Source']\n[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@other' = 'UCBR8-60-B28hp2BmDPdntcQ'\n[categories]\nEmpty = []\nMixed = ['@a', '@other']\nShared = ['@a', '@b', '@other']\nSource = ['@a', '@b']\n"; + std::fs::write(&path, original).unwrap(); + let mut app = App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ); + loaded_channel_views(&mut app); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + app.view_index = app + .views + .iter() + .position(|view| view.name == "Source") + .unwrap(); + app.views[app.view_index].loaded = true; + app.views[1].loaded = true; + app.views[1].next_start = 41; + let subscriptions = app.feed_channels.clone(); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(dialog.tags["Shared"] && dialog.tags["Source"]); + assert!(!dialog.tags["Mixed"] && !dialog.tags["Empty"]); + assert!( + matches!(&dialog.target, CategoryTarget::Category { channels, .. } if channels.len() == 2) + ); + assert!(app.views[app.view_index].videos.is_empty()); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + for c in "Staged".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + assert!(!app.config.categories.contains_key("Staged")); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + let saved = config::load(&path).unwrap(); + assert!(saved.categories["Source"].is_empty()); + assert_eq!( + saved.categories["Mixed"], + ["https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"] + ); + assert_eq!(saved.categories["Shared"].len(), 3); + assert_eq!(saved.pinned_categories, ["Source"]); + assert_eq!(app.feed_channels, subscriptions); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].next_start, 41); + browse_channel(&mut app, "@a"); + let channel_source = app.views[app.view_index].source.clone(); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + let channel = &app.views[app.view_index]; + assert_eq!(channel.source, channel_source); + assert!(channel.loaded); + assert_eq!(channel.videos.len(), 2); + assert_eq!(channel.selection.selected(), Some(1)); + assert_eq!(channel.next_start, 41); + assert!(app.job.is_none()); + app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT)); + assert!(app.category_dialog.is_none()); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn filtering_preserves_selected_categories_and_browses_the_filtered_row() { + let mut app = app(&["tubular"]); + app.views[0].query = "main search untouched".into(); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Japanese immersion".into(), vec![]), + ("Japanese news".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + for c in "JAPANESE".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + for c in " news".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].name, "Japanese news"); + assert_eq!(app.views[0].query, "main search untouched"); + assert!(app.browser.is_none()); +} + +#[test] +fn insert_filter_types_jk_handles_empty_matches_and_navigates_without_editing() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Jazz".into(), vec![]), + ("Jokes".into(), vec![]), + ("日本語だけで".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + for c in "jk".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + assert!(app.browser.as_ref().unwrap().selection.selected().is_none()); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.browser.is_some()); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + for (code, modifiers, row) in [ + (KeyCode::Char('j'), KeyModifiers::CONTROL, 1), + (KeyCode::Char('k'), KeyModifiers::CONTROL, 0), + (KeyCode::Down, KeyModifiers::NONE, 1), + (KeyCode::Up, KeyModifiers::NONE, 0), + ] { + app.key(KeyEvent::new(code, modifiers)); + assert_eq!( + app.browser.as_ref().unwrap().selection.selected(), + Some(row) + ); + } + assert_eq!(app.browser.as_ref().unwrap().query, "j"); + app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + for c in "日本".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].name, "日本語だけで"); + assert_eq!(app.mode, Mode::Normal); +} + +#[test] +fn category_browser_replaces_the_temporary_tab_and_cycles_only_visible_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Gamma".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + let visible = |app: &App| { + app.visible_views() + .map(|(_, view)| view.name.clone()) + .collect::>() + }; + assert_eq!(visible(&app), ["Search", "Feed"]); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].name, "Beta"); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]); + app.apply_config(config::Config { + categories: app.config.categories.clone(), + pinned_categories: vec!["Beta".into()], + ..config::Config::default() + }); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].name, "Gamma"); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].name, "Beta"); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + assert!(app.browser.is_none()); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); +} + +#[test] +fn categories_named_search_or_feed_are_distinct_from_permanent_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[app.view_index].name, "Feed"); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); +} + +#[test] +fn descriptions_are_shared_across_views_and_evict_least_recently_used_entries() { + let mut app = app(&["tubular"]); + let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared video"}]}"#; + for view in &mut app.views { + view.videos = youtube::search_results(output).unwrap().videos; + view.selection.select(Some(0)); + view.loaded = true; + } + app.remember_description("jNQXAC9IVRw".into(), "Shared full description".into()); + for index in 1..DESCRIPTION_CACHE_SIZE { + app.remember_description(format!("video{index:06}"), format!("Description {index}")); + } + app.switch_view(1); + app.load_description(); // A hit in Feed promotes the Search video's description. + assert!(app.description_job.is_none()); + app.remember_description("new00000000".into(), "New description".into()); + assert_eq!( + cached_description(&app.descriptions, "jNQXAC9IVRw"), + Some("Shared full description") + ); + assert_eq!(cached_description(&app.descriptions, "video000001"), None); + assert_eq!( + cached_description(&app.descriptions, "video000002"), + Some("Description 2") + ); + // Updating an existing entry, including a known empty description, must not evict another. + app.remember_description("jNQXAC9IVRw".into(), String::new()); + app.load_description(); + assert!(app.description_job.is_none()); + assert_eq!( + cached_description(&app.descriptions, "jNQXAC9IVRw"), + Some("") + ); + assert_eq!( + cached_description(&app.descriptions, "video000002"), + Some("Description 2") + ); +} + +#[test] +fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() { + use ratatui::{Terminal, backend::TestBackend, style::Modifier}; + + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#, + ).unwrap().videos; + app.views[0].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + for (last_line, hidden) in [ + ("line", false), + ("line", true), + ("abcdefghijklmnopqrs", true), + ("a界界界界界界界界界", true), + ("", true), + ] { + app.remember_description( + "jNQXAC9IVRw".into(), + format!( + "{}{last_line}{}", + "line\n".repeat(6), + if hidden { "\nhidden content" } else { "" }, + ), + ); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let popup = preview_area(buffer.area); + let text_x = (popup.x + 1..popup.right() - 1) + .find(|&x| { + let cell = &buffer[(x, popup.y + 1)]; + cell.symbol() == "H" && cell.modifier.contains(Modifier::BOLD) + }) + .expect("title is visible"); + let row: String = (text_x..popup.right() - 3) + .map(|x| buffer[(x, popup.bottom() - 2)].symbol()) + .collect(); + if hidden { + assert!(row.trim_end().ends_with('…'), "{row}"); + if last_line.starts_with("a界") { + assert!(row.starts_with("a界")); + for x in text_x..popup.right() - 3 { + if buffer[(x, popup.bottom() - 2)].symbol() == "界" { + assert_eq!(buffer[(x + 1, popup.bottom() - 2)].symbol(), " "); + } + } + } + } else { + assert_eq!(row.trim_end(), "line"); + } + } +} + +#[test] +fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() { + use ratatui::{Terminal, backend::TestBackend, style::Modifier}; + + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#, + ).unwrap().videos; + app.remember_description( + "jNQXAC9IVRw".into(), + format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)), + ); + app.views[0].selection.select(Some(0)); + app.preview = Some(PreviewMode::Info); + for (width, height) in [(80, 24), (120, 40), (20, 10)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let popup = preview_area(Rect::new(0, 0, width, height)); + let mut excerpt_rows = Vec::new(); + for y in popup.y + 1..popup.bottom() - 1 { + for x in popup.right() - 3..popup.right() - 1 { + assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " "); + } + let row: String = (popup.x + popup.width / 2..popup.right() - 3) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect(); + assert!(!row.contains("CLIPPED SENTINEL")); + if row.contains("Excerpt line") { + excerpt_rows.push(y); + } + } + if width == 20 { + assert!(excerpt_rows.is_empty()); // Heading takes all available height. + continue; + } + let text_x = (popup.x + 1..popup.right() - 1) + .find(|&x| { + let cell = &buffer[(x, popup.y + 1)]; + cell.symbol() == "F" && cell.modifier.contains(Modifier::BOLD) + }) + .expect("title is visible"); + let title = &buffer[(text_x, popup.y + 1)]; + assert!(title.modifier.contains(Modifier::BOLD)); + assert!(!title.modifier.contains(Modifier::DIM)); + let excerpt_y = *excerpt_rows + .last() + .expect("description fills remaining space"); + assert_eq!(excerpt_y, popup.bottom() - 2); + let excerpt = &buffer[(text_x, excerpt_y)]; + assert!(!excerpt.modifier.contains(Modifier::DIM)); + assert!(!excerpt.modifier.contains(Modifier::BOLD)); + } +} + +#[test] +fn view_tabs_wrap_whole_labels_and_keep_the_selected_row_visible() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + app.views = ["Search", "Feed", "Language immersion", "日本語だけ", "中文"] + .into_iter() + .enumerate() + .map(|(index, name)| { + View::new( + name.into(), + match index { + 0 => ViewSource::Search, + 1 => ViewSource::Feed, + _ => ViewSource::Category(name.into()), + }, + ) + }) + .collect(); + app.config.pinned_categories = app + .views + .iter() + .skip(2) + .map(|view| view.name.clone()) + .collect(); + app.view_index = 4; + app.mode = Mode::Insert; + for (width, height) in [(30, 20), (56, 20), (55, 20), (9, 20), (30, 12)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let tab_height = terminal.get_cursor_position().unwrap().y - 1; + let buffer = terminal.backend().buffer(); + let locate = |symbol: &str| { + (0..tab_height) + .flat_map(|y| (0..width).map(move |x| (x, y))) + .find(|&position| buffer.cell(position).unwrap().symbol() == symbol) + }; + let chinese = locate("中").expect("selected category remains visible"); + assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan); + assert_eq!( + buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(), + "文" + ); + if width >= 16 { + let japanese = locate("日").expect("neighboring category remains visible"); + for (offset, character) in "日本語だけ".chars().enumerate() { + assert_eq!( + buffer + .cell((japanese.0 + offset as u16 * 2, japanese.1)) + .unwrap() + .symbol(), + character.to_string(), + "category label must not split across rows" + ); + } + assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan); + } + if width >= 24 { + assert!( + (0..tab_height).any(|y| { + (0..width) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect::() + .contains("Language immersion") + }), + "whole category label must fit after wrapping" + ); + } + } +} + +#[test] +fn search_pages_preserve_relevance_and_selection() { + let mut view = View::new("Search".into(), ViewSource::Search); + let mut first = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#, + ) + .unwrap(); + first.has_more = true; + view.accept_page(first, false).unwrap(); + view.selection.select(Some(1)); + let mut next = youtube::search_results( + r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Duplicate"},{"id":"l-GaaivVJCE","title":"Relevant","timestamp":50},{"id":"l-GaaivVJCE","title":"Repeated"},{"id":"aaaaaaaaaaa","title":"Later","timestamp":300}]}"#, + ) + .unwrap(); + next.has_more = true; + view.accept_page(next, false).unwrap(); + assert_eq!( + view.videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"] + ); + assert_eq!(view.videos[1].title, "Second"); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.next_start, PAGE_SIZE * 2 + 1); + assert!(view.has_more); +} + +#[test] +fn many_channel_feed_consumes_only_sixty_requested_rows() { + let mut app = app(&["tubular"]); + app.view_index = 1; + app.feed_channels = (0..75) + .map(|index| format!("https://www.youtube.com/@creator{index}")) + .collect(); + let view = &mut app.views[1]; + view.feed_requests = view.feed_cursor.plan(app.feed_channels.len()).unwrap(); + assert_eq!( + view.feed_requests + .iter() + .map(|request| request.count) + .sum::(), + PAGE_SIZE + ); + assert_eq!(view.feed_requests.len(), PAGE_SIZE); + let output = view + .feed_requests + .iter() + .map(|request| { + serde_json::json!({"entries":[{ + "id":format!("channel{:04}", request.channel), + "title":"Upload", + "timestamp":request.channel, + }]}) + .to_string() + }) + .collect::>() + .join("\n"); + app.finish_page(Ok(output)); + assert_eq!(app.views[1].videos.len(), PAGE_SIZE); + assert!(app.views[1].has_more); + assert!(!app.can_prefetch()); + let next = app.views[1] + .feed_cursor + .plan(app.feed_channels.len()) + .unwrap(); + assert!( + next.iter() + .any(|request| request.channel >= PAGE_SIZE && request.start == 1) + ); + assert_eq!( + next.iter().map(|request| request.count).sum::(), + PAGE_SIZE + ); + app.views[1].feed_requests = next.clone(); + app.views[1].prefetch_requested = true; + app.finish_page(Ok(r#"{"entries":[]}"#.into())); + assert!(app.views[1].page_error.is_some()); + assert_eq!(app.views[1].videos.len(), PAGE_SIZE); + assert_eq!(app.views[1].next_start, PAGE_SIZE + 1); + assert_eq!( + app.views[1] + .feed_cursor + .plan(app.feed_channels.len()) + .unwrap(), + next + ); + assert!(app.views[1].prefetch_requested); +} + +#[test] +fn refresh_keeps_rows_until_a_valid_replacement_arrives() { + let mut app = app(&["tubular"]); + app.view_index = 1; + // Duplicate URLs fail request creation before any extractor is spawned. + app.feed_channels = vec!["fixture".into(), "fixture".into()]; + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old"},{"id":"rQ_J9WH6CGk","title":"Selected"}]} +{"entries":[]}"#, + false, + ); + app.views[1].selection.select(Some(1)); + app.load_view(); + assert_eq!(app.views[1].videos[1].title, "Selected"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + assert!(app.views[1].page_error.is_some()); + assert_eq!(app.views[1].next_start, 1); + + app.finish_page(Ok("not JSON".into())); + assert_eq!(app.views[1].videos[1].title, "Selected"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Updated"}]} +{"entries":[]}"#, + true, + ); + assert_eq!(app.views[1].videos.len(), 1); + assert_eq!(app.views[1].videos[0].title, "Updated"); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert_eq!(app.views[1].next_start, PAGE_SIZE + 1); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Next page"}]} +{"entries":[]}"#, + false, + ); + assert_eq!(app.views[1].videos[0].title, "Updated"); + assert_eq!(app.views[1].videos[1].title, "Next page"); + + app.load_view(); + assert_eq!(app.views[1].videos[0].title, "Updated"); + finish_feed_fixture(&mut app, "{\"entries\":[]}\n{\"entries\":[]}", false); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); +} + +#[test] +fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() { + let mut app = app(&["tubular"]); + app.view_index = 1; + app.feed_channels = vec![ + "https://www.youtube.com/@one".into(), + "https://www.youtube.com/@two".into(), + ]; + // Keep both channels active despite a short visible page. + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Undated"}]} +{"entries":[{"id":"l-GaaivVJCE","title":"Middle","timestamp":200}]}"#, + true, + ); + assert_eq!( + app.views[1] + .videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] + ); + app.views[1].selection.select(Some(1)); + let description_id = app.views[1].videos[1].id.clone(); + app.preview = Some(PreviewMode::Info); + app.remember_description("l-GaaivVJCE".into(), "Keep cached preview".into()); + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Newer","timestamp":300}]} +{"entries":[]}"#, + false, + ); + assert_eq!( + app.views[1] + .videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] + ); + assert_eq!(app.views[1].selection.selected(), Some(2)); + assert_eq!(app.views[1].videos[2].title, "Old"); + app.finish_description( + description_id, + Ok(r#"{"id":"jNQXAC9IVRw","description":"Selected upload description"}"#.into()), + ); + assert_eq!( + cached_description(&app.descriptions, "jNQXAC9IVRw"), + Some("Selected upload description") + ); + assert_eq!( + cached_description(&app.descriptions, "l-GaaivVJCE"), + Some("Keep cached preview") + ); + assert_eq!(cached_description(&app.descriptions, "SLV9AJxnb1U"), None); + assert_eq!(app.preview, Some(PreviewMode::Info)); + assert!(!app.views[1].has_more); +} + +#[test] +fn short_undated_or_invalid_feed_pages_require_new_forward_navigation() { + for visible in [0, 1, 19] { + let mut app = app(&["tubular"]); + app.view_index = 1; + app.feed_channels + .push("https://www.youtube.com/@jawed".into()); + let mut entries: Vec<_> = (0..visible) + .map(|index| { + serde_json::json!({ + "id":format!("unknown{index:04}"), "title":"Undated", + }) + }) + .collect(); + entries.resize(PAGE_SIZE - 1, serde_json::Value::Null); + entries.push(serde_json::json!({"id":"bad", "title":"Invalid ID"})); + finish_feed_fixture( + &mut app, + &serde_json::json!({"entries":entries}).to_string(), + false, + ); + assert_eq!(app.views[1].videos.len(), visible); + assert!(app.views[1].has_more); + assert!(!app.can_prefetch()); + app.update(); + assert!(app.job.is_none()); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert!(app.can_prefetch()); + assert_eq!( + app.views[1].selection.selected(), + if visible == 0 { + None + } else { + Some((visible - 1).min(1)) + } + ); + finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); + assert!(app.views[1].has_more); + assert!(!app.can_prefetch()); + app.key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)); + assert!(app.views[1].prefetch_requested); + let next = app.views[1].feed_cursor.plan(1).unwrap(); + app.finish_page(Err("Continuation failed".into())); + assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next); + assert!(!app.can_prefetch()); + app.views[1].reset(); + assert!(app.views[1].feed_requests.is_empty()); + assert!(!app.views[1].prefetch_requested); + assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1); + } +} + +#[test] +fn feed_cursors_and_staged_requests_survive_cancellation_and_history_independently() { + let mut app = app(&["tubular"]); + app.feed_channels + .push("https://www.youtube.com/@one".into()); + app.config + .categories + .insert("Two".into(), vec!["https://www.youtube.com/@two".into()]); + app.views + .push(View::new("Two".into(), ViewSource::Category("Two".into()))); + let category = app.views.len() - 1; + app.views[0].loaded = true; + for index in [1, category] { + app.view_index = index; + finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); + } + app.view_index = 1; + app.views[1].prefetch_requested = true; + app.views[1].feed_requests = app.views[1].feed_cursor.plan(1).unwrap(); + let cancelled = app.views[1].feed_requests.clone(); + app.switch_view(category); + finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true); + assert_eq!( + app.views[category].feed_cursor.plan(1).unwrap()[0].start, + PAGE_SIZE * 2 + 1 + ); + app.back_view(); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].feed_requests, cancelled); + assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), cancelled); + assert!(app.views[1].prefetch_requested); + app.forward_view(); + assert_eq!(app.view_index, category); + assert!(!app.views[category].prefetch_requested); + assert_eq!( + app.views[category].feed_cursor.plan(1).unwrap()[0].start, + PAGE_SIZE * 2 + 1 + ); +} + +#[test] +fn category_membership_reordering_keeps_cursor_request_intent_and_cache() { + let mut app = App::new( + Options::parse_from(["tubular"]), + PathBuf::new(), + config::Config { + categories: [( + "Creators".into(), + vec![ + "https://www.youtube.com/@two".into(), + "https://www.youtube.com/@one".into(), + ], + )] + .into(), + ..Default::default() + }, + ); + app.view_index = 2; + finish_feed_fixture( + &mut app, + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#, + true, + ); + app.views[2].query = " draft robotics ".into(); + app.views[2].submitted_query = "robotics".into(); + app.remember_description("jNQXAC9IVRw".into(), "Retained".into()); + app.views[2].feed_requests = app.views[2].feed_cursor.plan(2).unwrap(); + app.views[2].prefetch_requested = true; + let requests = app.views[2].feed_requests.clone(); + let mut categories = app.config.categories.clone(); + categories.get_mut("Creators").unwrap().reverse(); + app.apply_config(config::Config { + categories, + ..Default::default() + }); + assert_eq!(app.views[2].feed_requests, requests); + assert_eq!(app.views[2].feed_cursor.plan(2).unwrap(), requests); + assert!(app.views[2].prefetch_requested); + assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw"); + assert_eq!(app.views[2].selection.selected(), Some(0)); + assert_eq!(app.views[2].query, " draft robotics "); + assert_eq!(app.views[2].submitted_query, "robotics"); + assert_eq!( + cached_description(&app.descriptions, "jNQXAC9IVRw"), + Some("Retained") + ); + let mut categories = app.config.categories.clone(); + categories + .get_mut("Creators") + .unwrap() + .push("https://www.youtube.com/@three".into()); + app.apply_config(config::Config { + categories, + ..Default::default() + }); + assert!(!app.views[2].loaded); + assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw"); + assert!(app.views[2].feed_requests.is_empty()); + assert!(!app.views[2].prefetch_requested); + assert_eq!(app.views[2].next_start, 1); + assert_eq!(app.views[2].query, " draft robotics "); + assert_eq!(app.views[2].submitted_query, "robotics"); + assert!( + app.views[2] + .feed_cursor + .plan(3) + .unwrap() + .iter() + .all(|request| request.start == 1) + ); +} + +#[test] +fn page_failures_and_view_switches_preserve_the_successful_cursor_until_reset() { + let mut app = app(&["tubular", "rust"]); + app.views[0].submitted_query = "rust".into(); + app.finish_page(Err("First request failed".into())); + assert_eq!(app.views[0].next_start, 1); + assert!(!app.views[0].loaded); + assert!(app.views[0].page_error.is_some()); + app.switch_view(0); + app.update(); + assert!(app.job.is_none()); + app.views[0].reset(); + let entries: Vec<_> = (0..PAGE_SIZE) + .map(|index| { + serde_json::json!({ + "id": format!("search{index:05}"), + "title": "Result", + }) + }) + .collect(); + app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string())); + assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); + app.views[0].selection.select(Some(17)); + app.remember_description(app.views[0].videos[17].id.clone(), "Cached.".into()); + app.switch_view(1); + app.switch_view(0); + assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); + assert_eq!(app.views[0].selection.selected(), Some(17)); + app.finish_page(Ok("not JSON".into())); + app.update(); + assert!(app.job.is_none()); + assert_eq!(app.views[0].videos.len(), PAGE_SIZE); + assert_eq!( + cached_description(&app.descriptions, &app.views[0].videos[17].id), + Some("Cached.") + ); + assert_eq!(app.views[0].next_start, PAGE_SIZE + 1); + assert!(!app.views[0].needs_page()); + let view = &mut app.views[0]; + view.feed_requests = view.feed_cursor.plan(1).unwrap(); + view.prefetch_requested = true; + view.reset(); + assert!(view.feed_requests.is_empty()); + assert!(!view.feed_cursor.has_more()); + assert!(!view.prefetch_requested); + assert_eq!(view.next_start, 1); + assert!(!view.loaded); + assert!(!view.has_more); + assert!(view.page_error.is_none()); +} + +#[test] +fn prefetch_only_runs_near_the_end_in_an_unobstructed_normal_view() { + let mut app = app(&["tubular"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + let mut page = + youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Result"}]}"#).unwrap(); + page.has_more = true; + app.views[0].accept_page(page, false).unwrap(); + assert!(!app.can_prefetch()); + app.mode = Mode::Normal; + assert!(!app.can_prefetch()); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert!(app.can_prefetch()); + app.preview = Some(PreviewMode::Description); + assert!(!app.can_prefetch()); + app.preview = Some(PreviewMode::Info); + assert!(!app.can_prefetch()); + app.preview = None; + app.category_dialog = Some(CategoryDialog { + name: None, + ..CategoryDialog::new( + CategoryTarget::Channel { + channel: String::new(), + handle: String::new(), + }, + String::new(), + BTreeMap::new(), + ) + }); + assert!(!app.can_prefetch()); + app.category_dialog = None; + let entries: Vec<_> = (0..PAGE_SIZE) + .map(|index| { + serde_json::json!({ + "id": format!("search{index:05}"), + "title": "Next result", + }) + }) + .collect(); + app.views[0].reset(); + app.views[0] + .accept_page( + youtube::search_results(&serde_json::json!({"entries": entries}).to_string()).unwrap(), + false, + ) + .unwrap(); + assert_eq!(app.views[0].selection.selected(), Some(0)); + assert!(!app.can_prefetch()); + let threshold = app.views[0].videos.len() - PAGE_SIZE / 3; + app.views[0].selection.select(Some(threshold - 1)); + assert!(!app.can_prefetch()); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert!(app.can_prefetch()); + assert!(app.job.is_none()); + assert_eq!(app.views[0].selection.selected(), Some(threshold)); + app.views[0].loaded = false; + assert!(!app.can_prefetch()); + app.views[0].loaded = true; + app.views[0].has_more = false; + assert!(!app.can_prefetch()); + app.views[0].has_more = true; + app.help_popup = Some(HelpPopup::default()); + assert!(!app.can_prefetch()); + app.help_popup = None; + app.views[0].selection.select(None); + assert!(!app.can_prefetch()); + app.views[0].selection.select(Some(threshold)); + app.views[0].page_error = Some("Stop retrying".into()); + assert!(!app.can_prefetch()); + app.views[0].page_error = None; + app.views[0].videos.clear(); + app.views[0].selection.select(None); + assert!(app.can_prefetch()); +} + +#[test] +fn input_cursor_tracks_display_width_and_stays_inside_scrolled_fields() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + let mut terminal = Terminal::new(TestBackend::new(24, 20)).unwrap(); + for (text, x) in [ + ("", 1), + ("ab", 3), + ("日本語", 7), + ("e\u{301}", 2), + ("abcdefghijklmnopqrstuvwxyz", 22), + ] { + app.views[0].query = text.into(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into()); + } + app.category_dialog = Some(CategoryDialog { + name: Some(String::new()), + ..CategoryDialog::new( + CategoryTarget::Channel { + channel: String::new(), + handle: String::new(), + }, + String::new(), + BTreeMap::new(), + ) + }); + for (text, x) in [("", 3), ("日本語", 9), ("abcdefghijklmnopqrstuvwxyz", 20)] { + app.category_dialog.as_mut().unwrap().name = Some(text.into()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 7).into()); + } +} + +#[test] +fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() { + for tag_input in [false, true] { + for (input, code, modifiers, expected) in [ + ( + "first 日本語 ", + KeyCode::Backspace, + KeyModifiers::ALT, + "first ", + ), + ( + "first\u{3000}cafe\u{301} ", + KeyCode::Char('w'), + KeyModifiers::CONTROL, + "first\u{3000}", + ), + ("word", KeyCode::Backspace, KeyModifiers::ALT, ""), + (" \u{3000} ", KeyCode::Backspace, KeyModifiers::ALT, ""), + ("", KeyCode::Backspace, KeyModifiers::ALT, ""), + ("first 日本語", KeyCode::Backspace, KeyModifiers::SUPER, ""), + ( + "first 日本語", + KeyCode::Backspace, + KeyModifiers::SUPER | KeyModifiers::ALT, + "", + ), + ( + "first 日本語", + KeyCode::Char('u'), + KeyModifiers::CONTROL, + "", + ), + ( + "first 日本語", + KeyCode::Backspace, + KeyModifiers::NONE, + "first 日本", + ), + ] { + let mut app = app(&["tubular"]); + if tag_input { + app.mode = Mode::Normal; + app.category_dialog = Some(CategoryDialog { + name: Some(input.into()), + ..CategoryDialog::new( + CategoryTarget::Channel { + channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A" + .into(), + handle: "@jawed".into(), + }, + "jawed".into(), + BTreeMap::new(), + ) + }); + } else { + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.views[0].query = input.into(); + } + app.key(KeyEvent::new(code, modifiers)); + let edited = app + .category_dialog + .as_ref() + .and_then(|dialog| dialog.name.as_deref()) + .unwrap_or(&app.views[0].query); + assert_eq!( + edited, expected, + "{tag_input}: {input:?} {code:?} {modifiers:?}" + ); + assert!(app.job.is_none()); + assert_eq!( + app.mode, + if tag_input { + Mode::Normal + } else { + Mode::Insert + } + ); + } + } + let mut app = app(&["tubular"]); + app.views[0].query = "keep this query".into(); + app.mode = Mode::Normal; + for (code, modifiers) in [ + (KeyCode::Backspace, KeyModifiers::ALT), + (KeyCode::Backspace, KeyModifiers::SUPER), + (KeyCode::Char('w'), KeyModifiers::CONTROL), + ] { + app.key(KeyEvent::new(code, modifiers)); + assert_eq!(app.views[0].query, "keep this query"); + } +} + +#[test] +fn modal_keys_edit_or_navigate_without_submitting_on_escape() { + let mut app = app(&["tubular"]); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.views[0].videos = vec![ + Video { + id: "jNQXAC9IVRw".into(), + title: "Me at the zoo".into(), + channel: None, + channel_id: None, + uploader_id: None, + timestamp: None, + publication_date: None, + availability: None, + }, + Video { + id: "rQ_J9WH6CGk".into(), + title: "Rust course".into(), + channel: None, + channel_id: None, + uploader_id: None, + timestamp: None, + publication_date: None, + availability: None, + }, + ]; + app.views[0].selection.select(Some(0)); + + for c in ['j', 'k', 'q'] { + assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))); + } + app.update(); + assert_eq!(app.views[0].query, "jkq"); + assert_eq!(app.views[0].selection.selected(), Some(0)); + assert!(app.job.is_none()); + + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(app.mode, Mode::Normal); + assert!(app.job.is_none()); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)); + assert_eq!(app.views[0].selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + assert_eq!(app.mode, Mode::Insert); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.mode, Mode::Normal); + assert!(app.views[0].query.is_empty()); + assert!(app.job.is_none()); +} + +#[test] +fn category_dialog_is_modal_and_views_keep_independent_selections() { + let mut app = app(&["tubular", "rust"]); + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#; + app.views[0].videos = youtube::search_results(videos).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(1)); + // The selected channel is unavailable; do not save a guessed display-name identity. + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().error.is_some()); + for c in ['j', 'k', 'q', 'i', 't', 'd', ' '] { + assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE))); + } + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("jkqitd ") + ); + assert_eq!(app.views[0].query, "rust"); + assert_eq!(app.views[0].selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().name.is_none()); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().name.is_none()); + assert!(app.category_dialog.as_ref().unwrap().tags["A"]); + assert!(app.config.categories.is_empty()); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(!app.category_dialog.as_ref().unwrap().tags["A"]); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.category_dialog.as_ref().unwrap().tags["A"]); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + assert_eq!(app.mode, Mode::Normal); + assert!(app.config.categories.is_empty()); + + let rust = vec!["https://www.youtube.com/@RustVideos".into()]; + app.apply_config(config::Config { + keys: Default::default(), + categories: [("Rust".into(), rust.clone())].into(), + channels: BTreeMap::new(), + pinned_categories: vec!["Rust".into()], + }); + app.views[0].selection.select(Some(1)); + app.views[1].videos = feed_fixture(videos).unwrap().videos; + app.views[1].loaded = true; + app.views[1].selection.select(Some(0)); + app.views[2].videos = feed_fixture(videos).unwrap().videos; + app.views[2].loaded = true; + app.views[2].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].name, "Feed"); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 2); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + assert_eq!(app.views[2].selection.selected(), Some(1)); + assert_eq!(app.views[1].selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].selection.selected(), Some(0)); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.apply_config(config::Config { + keys: Default::default(), + categories: [ + ( + "Books".into(), + vec!["https://www.youtube.com/@example".into()], + ), + ("Rust".into(), rust), + ] + .into(), + channels: BTreeMap::new(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[3].name, "Rust"); + assert_eq!(app.views[3].selection.selected(), Some(1)); + assert!(app.views[3].loaded); + app.apply_config(config::Config { + keys: Default::default(), + categories: [("Rust".into(), vec![])].into(), + channels: BTreeMap::new(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.views[app.view_index].name, "Rust"); + assert!(!app.views[app.view_index].loaded); + assert!(app.job.is_none()); + app.load_page(); + assert!(app.views[app.view_index].videos.is_empty()); + assert!(app.views[app.view_index].selection.selected().is_none()); + assert!(app.views[app.view_index].loaded); +} + +#[test] +fn feed_cache_tracks_subscriptions_not_tag_memberships() { + let registered = "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA"; + let direct = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + let added = "https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"; + let mut app = App::new( + Options::parse_from(["tubular", "rust"]), + PathBuf::new(), + config::Config { + keys: Default::default(), + channels: [("@RustVideos".into(), registered.into())].into(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Tagged".into(), vec![registered.into()]), + ] + .into(), + pinned_categories: Vec::new(), + }, + ); + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#; + assert_eq!(app.feed_channels, vec![direct, registered]); + app.views[1].videos = feed_fixture(videos).unwrap().videos; + app.views[1].selection.select(Some(1)); + app.views[1].loaded = true; + app.views[1].next_start = 41; + app.views[1].has_more = true; + app.views[1].feed_requests = app.views[1].feed_cursor.plan(2).unwrap(); + app.views[1].prefetch_requested = true; + app.views[1].page_error = Some("Retained page error".into()); + app.remember_description( + app.views[1].videos[1].id.clone(), + "Retained description".into(), + ); + app.views[3].videos = feed_fixture(videos).unwrap().videos; + app.views[3].selection.select(Some(0)); + app.views[3].loaded = true; + app.views[3].next_start = 61; + app.views[3].has_more = true; + app.views[3].feed_requests = app.views[3].feed_cursor.plan(1).unwrap(); + app.views[3].prefetch_requested = true; + app.views[3].page_error = Some("Tag page failed".into()); + app.switch_view(1); + + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Renamed".into(), vec![registered.into()]), + ("Tagged".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 1); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].videos[1].id, "rQ_J9WH6CGk"); + assert_eq!(app.views[1].selection.selected(), Some(1)); + assert_eq!(app.views[1].next_start, 41); + assert!(app.views[1].has_more); + assert_eq!(app.views[1].feed_requests.len(), 2); + assert!(app.views[1].prefetch_requested); + assert_eq!( + app.views[1].page_error.as_deref(), + Some("Retained page error") + ); + assert_eq!( + cached_description(&app.descriptions, &app.views[1].videos[1].id), + Some("Retained description") + ); + assert!(!app.views[4].loaded); + assert_eq!(app.views[4].next_start, 1); + assert!(!app.views[4].has_more); + assert!(app.views[4].feed_requests.is_empty()); + assert!(!app.views[4].prefetch_requested); + assert!(app.views[4].page_error.is_none()); + + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Direct".into(), vec![direct.into()]), + ("Renamed".into(), vec![]), + ("Tagged".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.feed_channels, vec![direct, registered]); + assert!(app.views[1].loaded); + assert_eq!(app.views[1].videos.len(), 2); + assert_eq!(app.views[1].selection.selected(), Some(1)); + assert_eq!(app.views[1].next_start, 41); + assert!(app.views[1].has_more); + assert_eq!(app.views[1].feed_requests.len(), 2); + assert!(app.views[1].prefetch_requested); + assert!(app.views[1].page_error.is_some()); + + let mut channels = app.config.channels.clone(); + channels.insert("@YouTube".into(), added.into()); + app.apply_config(config::Config { + keys: Default::default(), + channels, + categories: app.config.categories.clone(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.feed_channels, vec![direct, added, registered]); + assert!(!app.views[1].loaded); + assert_eq!(app.views[1].next_start, 1); + assert!(!app.views[1].has_more); + assert!(app.views[1].feed_requests.is_empty()); + assert!(!app.views[1].prefetch_requested); + assert!(app.views[1].page_error.is_none()); + assert_eq!(app.view_index, 1); + assert!(app.job.is_none()); + + app.views[1].videos = feed_fixture(videos).unwrap().videos; + app.views[1].selection.select(Some(1)); + app.views[1].loaded = true; + let mut channels = app.config.channels.clone(); + channels.remove("@YouTube"); + app.apply_config(config::Config { + keys: Default::default(), + channels, + categories: app.config.categories.clone(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.feed_channels, vec![direct, registered]); + assert!(!app.views[1].loaded); + assert_eq!(app.view_index, 1); + assert!(app.job.is_none()); +} + +#[test] +fn builtins_and_same_named_tags_keep_distinct_identities() { + let mut app = app(&["tubular", "rust"]); + app.apply_config(config::Config { + keys: Default::default(), + channels: [( + "@RustVideos".into(), + "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(), + )] + .into(), + categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + pinned_categories: Vec::new(), + }); + let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; + for (index, view) in app.views.iter_mut().enumerate() { + view.videos = feed_fixture(videos).unwrap().videos; + view.loaded = true; + view.selection.select(Some(usize::from(index % 2 == 0))); + } + app.switch_view(1); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 1); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert_eq!(app.views[3].name, "Feed"); + assert_eq!(app.views[3].selection.selected(), Some(1)); + assert_eq!(app.views[0].selection.selected(), Some(1)); + assert_eq!(app.views[4].selection.selected(), Some(0)); + + app.switch_view(0); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 0); + app.switch_view(2); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[3].selection.selected(), Some(1)); + + app.switch_view(4); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 5); + assert_eq!(app.views[5].selection.selected(), Some(0)); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Feed".into(), vec![]), + ] + .into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.switch_view(4); + app.apply_config(config::Config { + keys: Default::default(), + channels: app.config.channels.clone(), + categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), + pinned_categories: Vec::new(), + }); + assert_eq!(app.view_index, 0); + assert_eq!(app.views[1].name, "Feed"); + assert_eq!(app.views[1].selection.selected(), Some(0)); + assert!(app.job.is_none()); +} + +#[test] +fn empty_feed_is_informative_and_navigation_wraps_without_extraction() { + let mut app = app(&["tubular"]); + assert_eq!(app.views.len(), 2); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL)); + assert_eq!(app.view_index, 1); + assert!(app.views[1].loaded); + assert!(app.views[1].videos.is_empty()); + assert_eq!(app.views[1].selection.selected(), None); + assert!(app.job.is_none()); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].source, ViewSource::Feed); + assert!(app.job.is_none()); +} + +#[test] +fn channel_tags_stage_memberships_and_commit_only_from_the_list() { + let path = std::env::temp_dir().join(format!( + "tubular-tag-editor-test-{}.toml", + std::process::id() + )); + struct TestFile(PathBuf); + impl Drop for TestFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let _file = TestFile(path.clone()); + let original = "[categories]\nAlias = [\"@RustVideos\"]\nCanonical = [\"UCaYhcUwRBNscFNUKTjgPFiA\"]\nEmpty = []\n"; + std::fs::write(&path, original).unwrap(); + let mut app = app(&["tubular", "rust"]); + app.config_path = path.clone(); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"}]}"#, + ) + .unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert_eq!( + app.category_dialog.as_ref().unwrap().tags, + [ + ("Alias".into(), true), + ("Canonical".into(), true), + ("Empty".into(), false), + ] + .into() + ); + app.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().tags["Alias"]); + app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); + for c in "Empty".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().error.is_some()); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + for c in " Added ".chars() { + app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)); + } + app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!( + app.category_dialog.as_ref().unwrap().selection.selected(), + Some(0) + ); + assert!(app.category_dialog.as_ref().unwrap().tags["Added"]); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + app.category_dialog.as_mut().unwrap().error = Some("Previous save failed".into()); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + assert!(app.category_dialog.as_ref().unwrap().error.is_none()); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + let saved = config::load(&path).unwrap(); + let canonical = vec!["https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".to_owned()]; + assert!(saved.categories["Alias"].is_empty()); + assert!(saved.categories["Added"].is_empty()); + assert_eq!(saved.categories["Canonical"], canonical); + assert_eq!(saved.categories["Empty"], canonical); + let committed = std::fs::read_to_string(&path).unwrap(); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(std::fs::read_to_string(&path).unwrap(), committed); + std::fs::write(&path, "[categories]\n").unwrap(); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert!( + app.category_dialog + .as_ref() + .unwrap() + .selection + .selected() + .is_none() + ); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(app.category_dialog.is_none()); + assert!(config::load(&path).unwrap().categories.is_empty()); +} + +#[test] +fn preview_cycles_without_reloading_and_selection_resets_scrolling() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"9bZkp7q19f0","title":"Second"}]}"#, + ).unwrap().videos; + // Invalid fixture IDs keep thumbnail failure deterministic without invoking curl. + app.views[0].videos[0].id = "first".into(); + app.views[0].videos[1].id = "second".into(); + app.remember_description("first".into(), "First paragraph\n\n".repeat(40)); + app.remember_description("second".into(), "Second paragraph\n\n".repeat(40)); + app.views[0].selection.select(Some(0)); + app.views[0].loaded = true; + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + let press = |app: &mut App, code| { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + }; + press(&mut app, KeyCode::Char('t')); + assert_eq!(app.preview, None); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Info)); + assert!(app.description_job.is_none()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let info = terminal.backend().buffer().clone(); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Description)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + press(&mut app, KeyCode::PageDown); + assert!(app.description_scroll > 0); + let scroll = app.description_scroll; + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Info)); + assert_eq!(app.description_scroll, scroll); + assert!(app.description_job.is_none()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + // Includes the failed thumbnail text: cancellation would remove that image state. + assert_eq!(terminal.backend().buffer(), &info); + press(&mut app, KeyCode::Char('d')); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.preview, Some(PreviewMode::Description)); + assert_eq!(app.views[0].selection.selected(), Some(1)); + assert_eq!(app.description_scroll, 0); + assert_eq!(app.description_viewport, None); + assert!(app.description_error.is_none()); + assert!(app.description_job.is_none()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let popup = preview_area(buffer.area); + let title = &buffer[(popup.x + 2, popup.y + 2)]; + assert_eq!(title.symbol(), "S"); + assert!(title.modifier.contains(ratatui::style::Modifier::BOLD)); + press(&mut app, KeyCode::Down); + assert_eq!(app.description_scroll, 1); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(app.description_scroll, 2); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.description_scroll, 1); + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(app.description_scroll, 0); + app.description_scroll = app.description_max_scroll; + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(app.description_scroll, app.description_max_scroll); + assert_eq!(app.views[0].selection.selected(), Some(1)); + press(&mut app, KeyCode::Char('d')); + press(&mut app, KeyCode::Up); + assert_eq!(app.views[0].selection.selected(), Some(0)); + assert_eq!(app.description_scroll, 0); + press(&mut app, KeyCode::Esc); + assert_eq!(app.preview, None); + assert_eq!( + cached_description(&app.descriptions, "first"), + Some("First paragraph\n\n".repeat(40).as_str()) + ); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Char('t')); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.views[0].query, "td"); + assert_eq!(app.preview, None); +} + +#[test] +fn full_description_scrolls_padded_metadata_and_body_together() { + use ratatui::{Terminal, backend::TestBackend, style::Modifier}; + + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Selected video","channel":"Creator","upload_date":"20050423"}]}"#, + ).unwrap().videos; + app.views[0].selection.select(Some(0)); + app.preview = Some(PreviewMode::Description); + app.remember_description( + "jNQXAC9IVRw".into(), + (0..100).map(|i| format!("Body line {i:03}\n")).collect(), + ); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let before = terminal.backend().buffer().clone(); + let popup = preview_area(before.area); + let x = popup.x + 2; + let title_y = popup.y + 2; + let body_y = (title_y..popup.bottom() - 1) + .find(|&y| before[(x, y)].symbol() == "B") + .expect("description is visible below metadata"); + for x in popup.x + 1..popup.right() - 1 { + assert_eq!(before[(x, popup.y + 1)].symbol(), " "); + } + assert!(before[(x, title_y)].modifier.contains(Modifier::BOLD)); + assert!(before[(x, title_y + 2)].modifier.contains(Modifier::DIM)); + assert!(!before[(x, body_y)].modifier.contains(Modifier::DIM)); + assert_eq!( + app.description_viewport, + Some((popup.width - 4, popup.height - 2)) + ); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let after = terminal.backend().buffer(); + assert_eq!(after[(x, title_y - 1)].symbol(), "S"); + assert!(after[(x, title_y - 1)].modifier.contains(Modifier::BOLD)); + assert_eq!(after[(x, title_y + 1)].symbol(), "C"); + assert!(after[(x, title_y + 1)].modifier.contains(Modifier::DIM)); + let row = |buffer: &ratatui::buffer::Buffer, y| -> String { + (x..popup.right() - 2) + .map(|x| buffer[(x, y)].symbol()) + .collect() + }; + assert_eq!(row(&before, body_y).trim_end(), "Body line 000"); + assert_eq!(row(after, body_y).trim_end(), "Body line 001"); + for y in popup.y + 1..popup.bottom() - 1 { + assert_eq!(after[(popup.x + 1, y)].symbol(), " "); + assert_eq!(after[(popup.right() - 2, y)].symbol(), " "); + } + // A wrapped title cannot prevent reaching the description in a small pane. + let mut narrow = Terminal::new(TestBackend::new(12, 8)).unwrap(); + narrow + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert!(app.description_max_scroll > 0); + app.description_scroll = app.description_max_scroll; + narrow + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let narrow_popup = preview_area(narrow.backend().buffer().area); + let narrow_buffer = narrow.backend().buffer(); + let tail: String = (narrow_popup.y + 1..narrow_popup.bottom() - 1) + .flat_map(|y| { + (narrow_popup.x + 2..narrow_popup.right() - 2) + .map(move |x| narrow_buffer[(x, y)].symbol()) + }) + .collect(); + assert!(tail.contains("099"), "{tail}"); +} + +#[test] +fn search_without_a_handle_can_tag_a_registered_channel() { + let path = std::env::temp_dir().join(format!( + "tubular-missing-handle-{}.toml", + std::process::id() + )); + let original = + "[channels]\n'@ch.1676' = 'UChAQQ9at2MDlXjvQX3vL3Yw'\n[categories]\nJapanese = []\n"; + std::fs::write(&path, original).unwrap(); + let mut app = App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"nwxkg84mADI","title":"Hokkaido Vlog","channel":"敷嶋てとらch.","channel_id":"UChAQQ9at2MDlXjvQX3vL3Yw","uploader_id":null}]}"#, + ).unwrap().videos; + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); + assert!(app.category_dialog.is_some(), "{}", app.status); + app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let saved = config::load(&path).unwrap(); + assert_eq!( + saved.categories["Japanese"], + ["https://www.youtube.com/channel/UChAQQ9at2MDlXjvQX3vL3Yw"] + ); + std::fs::remove_file(path).unwrap(); +} diff --git a/tests/unit/config.rs b/tests/unit/config.rs new file mode 100644 index 0000000..17fad16 --- /dev/null +++ b/tests/unit/config.rs @@ -0,0 +1,1195 @@ +use super::*; + +struct TestDirectory(PathBuf); + +impl TestDirectory { + fn new() -> Self { + loop { + let path = std::env::temp_dir().join(format!( + "tubular-config-test-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + match fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("Cannot create test directory: {error}"), + } + } + } +} + +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn existing_edge_separator_handles_can_be_saved_and_reloaded() { + let channel = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + for handle in ["@hontame_", "@_creator", "@.creator-"] { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("Immersion".to_owned(), true)]); + let saved = set_channel_tags(&path, &tags, handle, channel).unwrap(); + assert_eq!(saved.channels[handle], channel); + assert_eq!(saved.categories["Immersion"], [channel]); + let reloaded = load(&path).unwrap(); + assert_eq!(reloaded.channels, saved.channels); + assert_eq!(reloaded.categories, saved.categories); + let unchecked = BTreeMap::from([("Immersion".to_owned(), false)]); + set_channel_tags( + &path, + &unchecked, + &format!("https://www.youtube.com/{handle}"), + channel, + ) + .unwrap(); + let reloaded = load(&path).unwrap(); + assert!(reloaded.categories["Immersion"].is_empty()); + assert_eq!(reloaded.channels[handle], channel); + assert_eq!(reloaded.channels.len(), 1); + } +} + +#[test] +fn key_remaps_survive_all_saves_and_section_orders() { + use crate::keys::{Action, Context}; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; + let sections = [ + ("keys", key_section), + ("settings", "[settings]\npinned_categories = []\n"), + ( + "channels", + "[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n", + ), + ("categories", "[categories]\nA = ['@example']\nB = []\n"), + ]; + for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] { + let original = order + .iter() + .map(|index| sections[*index].1) + .collect::(); + fs::write(&path, original).unwrap(); + let loaded = load(&path).unwrap(); + for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('x'), modifiers) + ), + Some(Action::Quit), + ); + } + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE) + ), + None, + ); + assert_eq!( + loaded.keys.resolve( + Context::SelectorNormal, + KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) + ), + Some(Action::SelectorPin), + ); + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) + ), + None + ); + let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); + let channel_saved = + set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]); + let category_saved = + set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap(); + let pin_saved = set_category_pinned(&path, "B", true).unwrap(); + for config in [ + channel_saved, + category_saved, + pin_saved, + load(&path).unwrap(), + ] { + assert_eq!( + config.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE) + ), + Some(Action::Quit) + ); + assert_eq!( + config.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) + ), + None + ); + assert_eq!( + config.keys.resolve( + Context::SelectorNormal, + KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) + ), + Some(Action::SelectorPin) + ); + } + let saved = fs::read_to_string(&path).unwrap(); + assert!( + saved.contains(key_section), + "key formatting changed: {saved}" + ); + let document = saved.parse::().unwrap(); + assert_eq!( + document.iter().map(|(name, _)| name).collect::>(), + order + .iter() + .map(|index| sections[*index].0) + .collect::>(), + ); + } +} + +#[test] +fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); + let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()]; + for invalid_keys in [ + "[keys]\napp.quit = 42\n", + "[keys]\nunknown.action = 'x'\n", + "[keys]\napp.quit = 'not-a-key'\n", + "[keys]\nview.refresh = 'q'\n", + "keys = 'invalid'\n", + ] { + let original = format!( + "{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n" + ); + fs::write(&path, &original).unwrap(); + assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData); + let channel_error = + set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err(); + assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err(); + assert_eq!(category_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + let pin_error = set_category_pinned(&path, "B", true).unwrap_err(); + assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + let create_error = create_category(&path, "New", &expected).unwrap_err(); + assert_eq!(create_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } +} + +#[test] +fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let sections = [ + ( + "settings", + "[settings]\npinned_categories = ['A']\ncustom = 42\n", + ), + ( + "channels", + "[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n", + ), + ("categories", "[categories]\nA = []\nB = ['@example']\n"), + ]; + for order in [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] { + let original = order + .iter() + .map(|index| sections[*index].1) + .collect::(); + fs::write(&path, original).unwrap(); + let config = load(&path).unwrap(); + assert_eq!(config.pinned_categories, ["A"]); + assert_eq!( + config.categories["B"], + ["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"] + ); + assert_eq!( + set_category_pinned(&path, "B", true) + .unwrap() + .pinned_categories, + ["A", "B"] + ); + assert_eq!( + set_category_pinned(&path, "A", false) + .unwrap() + .pinned_categories, + ["B"] + ); + let saved = load(&path).unwrap(); + assert_eq!(saved.pinned_categories, ["B"]); + assert_eq!(saved.categories, config.categories); + assert_eq!(saved.channels, config.channels); + let contents = fs::read_to_string(&path).unwrap(); + let document = contents.parse::().unwrap(); + assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); + assert_eq!( + document.iter().map(|(name, _)| name).collect::>(), + order + .iter() + .map(|index| sections[*index].0) + .collect::>(), + ); + } + for original in [ + "[categories]\nA = []\n", + "[settings]\ncustom = 42\n[categories]\nA = []\n", + ] { + fs::write(&path, original).unwrap(); + set_category_pinned(&path, "A", true).unwrap(); + assert_eq!(load(&path).unwrap().pinned_categories, ["A"]); + } +} + +#[test] +fn category_pins_persist_without_changing_memberships_or_manual_preferences() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap(); + let pinned = set_category_pinned(&path, "Chinese", true).unwrap(); + assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]); + let tags = BTreeMap::from([("Japanese".into(), true)]); + let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); + assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]); + assert_eq!( + saved.categories["Japanese"], + ["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"] + ); + let unpinned = set_category_pinned(&path, "Japanese", false).unwrap(); + assert_eq!(unpinned.pinned_categories, ["Chinese"]); + assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# favorites") && content.contains("# pin note")); + assert_eq!( + content.parse::().unwrap()["other"]["value"].as_integer(), + Some(42) + ); + assert!(set_category_pinned(&path, "Missing", true).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + for invalid in [ + "[settings]\npinned_categories = true", + "[settings]\npinned_categories = [4]", + "[settings]\npinned_categories = ['A', ' A ']", + "settings = []", + "pinned_categories = ['A']", + ] { + fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap(); + let before = fs::read(&path).unwrap(); + assert!(set_category_pinned(&path, "A", true).is_err()); + assert!(set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + } +} + +#[test] +fn category_creation_deduplicates_aliases_and_preserves_original_config() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n"; + fs::write(&path, original).unwrap(); + let before = load(&path).unwrap(); + let created = create_category( + &path, + " Combined ", + &[ + id.to_owned(), + url.clone(), + "@zeta".to_owned(), + "https://youtube.com/@alpha".to_owned(), + "@manual".to_owned(), + "https://www.youtube.com/@manual".to_owned(), + "UCabcdefghijklmnopqrstuv".to_owned(), + ], + ) + .unwrap(); + assert_eq!( + created.categories["Combined"], + [ + "https://www.youtube.com/@manual".to_owned(), + url, + "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(), + ] + ); + assert_eq!(created.channels, before.channels); + assert_eq!(created.categories["Source"], before.categories["Source"]); + assert_eq!(created.categories["Empty"], before.categories["Empty"]); + assert_eq!(created.pinned_categories, before.pinned_categories); + let contents = fs::read_to_string(&path).unwrap(); + let mut document = contents.parse::().unwrap(); + assert_eq!( + document["categories"]["Combined"] + .as_array() + .unwrap() + .iter() + .map(|entry| entry.as_str().unwrap()) + .collect::>(), + [ + "@manual", + "@alpha", + "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv", + ] + ); + document["categories"] + .as_table_like_mut() + .unwrap() + .remove("Combined"); + assert_eq!(document.to_string(), original); + assert_eq!(load(&path).unwrap().categories, created.categories); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n"; + fs::write(&path, original).unwrap(); + let opened = load(&path).unwrap(); + let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv"); + fs::write(&path, &latest).unwrap(); + let created = create_category( + &path, + " 日本語 🎵 ", + &["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()], + ) + .unwrap(); + assert_ne!(created.channels, opened.channels); + assert_eq!( + created.categories["日本語 🎵"], + ["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"] + ); + assert_eq!(created.pinned_categories, ["Source"]); + let contents = fs::read_to_string(&path).unwrap(); + let document = contents.parse::().unwrap(); + let before_document = latest.parse::().unwrap(); + assert!(document["categories"].is_inline_table()); + assert_eq!( + document["categories"]["日本語 🎵"][0].as_str(), + Some("@mapped") + ); + for section in ["settings", "channels"] { + assert_eq!( + document[section].to_string(), + before_document[section].to_string() + ); + } + for name in ["Source", "Empty"] { + assert_eq!( + document["categories"][name].to_string(), + before_document["categories"][name].to_string() + ); + } + assert!(contents.contains("# categories note")); + let empty = create_category(&path, "New empty", &[]).unwrap(); + assert!(empty.categories["New empty"].is_empty()); + assert_eq!(empty.pinned_categories, ["Source"]); + let missing = directory.0.join("nested/config.toml"); + let empty = create_category(&missing, "Empty", &[]).unwrap(); + assert!(empty.categories["Empty"].is_empty()); + assert!(empty.channels.is_empty()); + assert!(empty.pinned_categories.is_empty()); + assert_eq!(load(&missing).unwrap().categories, empty.categories); +} + +#[test] +fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n"; + fs::write(&path, original).unwrap(); + for name in [ + "", + " ", + "Bad\nName", + "Bad\u{7f}Name", + "Source", + " Source ", + "Empty", + ] { + assert!(create_category(&path, name, &[]).is_err(), "{name:?}"); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } + // Even an identical membership snapshot must not overwrite a category. + assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err()); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + for channel in [ + "", + "bad", + "@bad handle", + "@manual\n", + "https://example.com/@manual", + "https://www.youtube.com/@manual/videos", + ] { + assert!( + create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(), + "{channel:?}" + ); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } + for malformed in [ + "[categories\n", + "categories = 3\n", + "[categories]\nSource = [7]\n", + "[categories]\nSource = []\n' Source ' = []\n", + "[channels]\n'@mapped' = '@other'\n", + "[settings]\npinned_categories = [7]\n", + "[keys.app]\nunknown = ['q']\n", + ] { + fs::write(&path, malformed).unwrap(); + assert!(create_category(&path, "New", &[]).is_err()); + assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes()); + } + let missing = directory.0.join("missing/config.toml"); + assert!(create_category(&missing, "Bad\nName", &[]).is_err()); + assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err()); + assert!(!missing.parent().unwrap().exists()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_rename_preserves_memberships_pins_comments_and_section_order() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n"; + fs::write(&path, original).unwrap(); + let before = load(&path).unwrap(); + let before_document = original.parse::().unwrap(); + let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap(); + assert!(!renamed.categories.contains_key("Source")); + assert_eq!( + renamed.categories["Renamed group"], + before.categories["Source"] + ); + assert_eq!(renamed.categories["Keep"], before.categories["Keep"]); + assert_eq!(renamed.channels, before.channels); + assert_eq!(renamed.subscriptions(), before.subscriptions()); + assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]); + let contents = fs::read_to_string(&path).unwrap(); + let document = contents.parse::().unwrap(); + assert_eq!( + document.iter().map(|(name, _)| name).collect::>(), + ["other", "settings", "keys", "channels", "categories"] + ); + assert_eq!( + document["categories"] + .as_table_like() + .unwrap() + .iter() + .map(|(name, _)| name) + .collect::>(), + ["Before", "Renamed group", "Keep"] + ); + assert_eq!( + document["categories"]["Renamed group"].to_string(), + before_document["categories"][" Source "].to_string() + ); + for section in ["other", "keys", "channels"] { + assert_eq!( + document[section].to_string(), + before_document[section].to_string() + ); + } + assert_eq!(document["settings"]["player"].as_str(), Some("mpv")); + for comment in [ + "# manual preferences", + "# favorite", + "# pin note", + "# category note", + "# entry note", + "# array note", + "# key note", + "# mapping note", + ] { + assert!(contents.contains(comment), "lost {comment}"); + } + assert_eq!(load(&path).unwrap().categories, renamed.categories); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_rename_preserves_inline_tables_and_reads_latest_memberships() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n"; + fs::write(&path, original).unwrap(); + let opened = load(&path).unwrap(); + let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'"); + fs::write(&path, &latest).unwrap(); + let renamed = rename_category(&path, "Source", "Renamed").unwrap(); + assert_eq!(opened.categories["Source"].len(), 1); + assert_eq!( + renamed.categories["Renamed"], + [ + "https://www.youtube.com/@first", + "https://www.youtube.com/@late", + ] + ); + assert!(!renamed.categories.contains_key("Source")); + assert_eq!(renamed.pinned_categories, ["Renamed"]); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!( + contents, + latest + .replace("Source =", "Renamed =") + .replace("'Source'", "\"Renamed\"") + ); + let document = contents.parse::().unwrap(); + assert!(document["categories"].is_inline_table()); + assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n"; + fs::write(&path, original).unwrap(); + for (old, new) in [ + ("Source", ""), + ("Source", " \t "), + ("Source", "Bad\nName"), + ("", "New"), + ("Missing", "New"), + ("Missing", "Missing"), + ("Source", "Keep"), + ("Source", " Keep "), + ] { + assert!( + rename_category(&path, old, new).is_err(), + "{old:?} -> {new:?}" + ); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } + let unchanged = rename_category(&path, " Source ", "Source").unwrap(); + assert_eq!(unchanged.categories, load(&path).unwrap().categories); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + for invalid in [ + "[settings]\npinned_categories = [7]\n[categories]\nSource = []\n", + "[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n", + "[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n", + "[categories]\nSource = [7]\n", + "[categories]\nSource = []\n' Source ' = []\n", + ] { + fs::write(&path, invalid).unwrap(); + assert!(rename_category(&path, "Source", "New").is_err()); + assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes()); + } + let missing = directory.0.join("missing/config.toml"); + assert!(rename_category(&missing, "Source", "New").is_err()); + assert!(!missing.parent().unwrap().exists()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let first = "UC4QobU6STFB0P71PMvOGN5A"; + let second = "UCabcdefghijklmnopqrstuv"; + let direct = "UC0123456789012345678901"; + let first_url = format!("https://www.youtube.com/channel/{first}"); + let second_url = format!("https://www.youtube.com/channel/{second}"); + let direct_url = format!("https://www.youtube.com/channel/{direct}"); + let manual_url = "https://www.youtube.com/@manual".to_owned(); + let original = format!( + "# manual preferences\n[settings]\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n" + ); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + let before = original.parse::().unwrap(); + // This category was not present in the dialog's tags. + fs::write( + &path, + format!("{original}Late = ['@alias', '@unrelated'] # late note\n"), + ) + .unwrap(); + let tags = BTreeMap::from([ + ("Source".to_owned(), false), + ("Keep".to_owned(), true), + ("Drop".to_owned(), false), + (" New ".to_owned(), true), + ]); + let saved = set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap(); + assert!(saved.categories["Source"].is_empty()); + for name in ["Drop", "Late", "Untouched"] { + assert_eq!( + saved.categories[name], + ["https://www.youtube.com/@unrelated"] + ); + } + assert_eq!( + saved.categories["Keep"], + [ + first_url.clone(), + "https://www.youtube.com/@unrelated".to_owned(), + manual_url.clone(), + direct_url.clone(), + second_url.clone(), + ] + ); + assert_eq!( + saved.categories["New"], + [manual_url, direct_url, first_url, second_url] + ); + assert_eq!(saved.channels, opened.channels); + assert_eq!(saved.pinned_categories, ["Source"]); + let content = fs::read_to_string(&path).unwrap(); + let document = content.parse::().unwrap(); + assert_eq!( + document["channels"].to_string(), + before["channels"].to_string() + ); + assert_eq!( + document["settings"]["pinned_categories"].to_string(), + before["settings"]["pinned_categories"].to_string() + ); + assert_eq!(document["other"]["value"].as_integer(), Some(42)); + assert_eq!(document["settings"]["player"].as_str(), Some("mpv")); + let new = document["categories"]["New"].as_array().unwrap(); + let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect(); + assert_eq!(raw, ["@manual", direct, "@alias", "@second"]); + let keep = document["categories"]["Keep"].as_array().unwrap(); + assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias")); + assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated")); + for comment in [ + "# manual preferences", + "# pin note", + "# alias mapping", + "# source channel", + "# direct channel", + "# source note", + "# keep note", + "# removed channel note", + "# unrelated note", + "# late note", + ] { + assert!(content.contains(comment), "lost {comment}"); + } + let reloaded = load(&path).unwrap(); + assert_eq!(reloaded.categories, saved.categories); + assert_eq!(reloaded.subscriptions(), saved.subscriptions()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); +} + +#[test] +fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n"); + fs::write( + &path, + format!("{registry}[categories]\nSource = ['@old', '@manual']\n"), + ) + .unwrap(); + let expected = load(&path).unwrap().categories.remove("Source").unwrap(); + fs::write( + &path, + format!( + "{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n" + ), + ) + .unwrap(); + let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]); + let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap(); + assert_eq!( + saved.categories["Source"], + ["https://www.youtube.com/@manual".to_owned(), url.clone()] + ); + assert_eq!( + saved.categories["New"], + ["https://www.youtube.com/@manual".to_owned(), url] + ); + let content = fs::read_to_string(&path).unwrap(); + let document = content.parse::().unwrap(); + let source = document["categories"]["Source"].as_array().unwrap(); + assert_eq!(source.len(), 3); + assert_eq!(source.get(2).unwrap().as_str(), Some("@alias")); + let new = document["categories"]["New"].as_array().unwrap(); + assert_eq!(new.get(1).unwrap().as_str(), Some("@alias")); + assert!(content.contains("# alias note")); + set_category_tags(&path, "Source", &expected, &tags).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), content); +} + +#[test] +fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("New".to_owned(), true)]); + let expected = vec!["https://www.youtube.com/@manual".to_owned()]; + for contents in [ + "[categories]\nOther = ['@manual']\n", + "[categories]\nSource = []\n", + "[categories]\nSource = ['@different']\n", + "[categories]\nSource = ['@manual', '@different']\n", + "[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n", + "[categories\n", + "categories = true\n", + "[categories]\nSource = ['@manual']\nInvalid = [7]\n", + "[categories]\nSource = ['@manual']\n' Source ' = []\n", + "channels = true\n[categories]\nSource = ['@manual']\n", + "[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n", + "[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n", + ] { + fs::write(&path, contents).unwrap(); + assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + fs::write(&path, [0xff]).unwrap(); + assert!(set_category_tags(&path, "Source", &expected, &tags).is_err()); + assert_eq!(fs::read(&path).unwrap(), [0xff]); + let valid = "[categories]\nSource = ['@manual']\n"; + fs::write(&path, valid).unwrap(); + for invalid_tags in [ + BTreeMap::from([(" ".to_owned(), true)]), + BTreeMap::from([("bad\nname".to_owned(), true)]), + BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]), + ] { + assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), valid); + } + assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), valid); + let missing = directory.0.join("missing/config.toml"); + assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err()); + assert!(!missing.parent().unwrap().exists()); +} + +#[test] +fn category_tags_only_clear_all_tags_when_every_target_remains_registered() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let registry = format!( + "[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n" + ); + for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] { + let original = format!("{registry}Source = [{source}]\n"); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + for tags in [ + BTreeMap::new(), + BTreeMap::from([("Source".to_owned(), false)]), + ] { + let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags) + .unwrap_err(); + assert!(error.to_string().contains("not registered in [channels]")); + assert_eq!(fs::read_to_string(&path).unwrap(), original); + } + } + let original = format!( + "{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n" + ); + fs::write(&path, &original).unwrap(); + let opened = load(&path).unwrap(); + let saved = set_category_tags( + &path, + "Source", + &opened.categories["Source"], + &BTreeMap::new(), + ) + .unwrap(); + assert!(saved.categories["Source"].is_empty()); + assert_eq!( + saved.categories["Other"], + ["https://www.youtube.com/@unrelated"] + ); + assert_eq!(saved.channels, opened.channels); + assert_eq!(saved.pinned_categories, ["Source"]); + assert_eq!( + saved.subscriptions(), + [ + "https://www.youtube.com/@unrelated", + "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A", + ] + ); + assert!(fs::read_to_string(&path).unwrap().contains("# source note")); +} + +#[test] +fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { + let directory = TestDirectory::new(); + let path = directory.0.join("nested/config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + assert!(load(&path).unwrap().categories.is_empty()); + assert!(!path.parent().unwrap().exists()); + let tags = BTreeMap::from([(" Music ".to_owned(), true)]); + let config = set_channel_tags(&path, &tags, "@example", id).unwrap(); + assert_eq!( + config.channels["@example"], + format!("https://www.youtube.com/channel/{id}") + ); + assert_eq!( + config.categories["Music"], + [format!("https://www.youtube.com/channel/{id}")] + ); + + let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n"; + fs::write(&path, manual).unwrap(); + let tags = BTreeMap::from([("Music".to_owned(), true)]); + let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap(); + assert_eq!(config.categories["Music"].len(), 1); + assert!(config.categories["Empty"].is_empty()); + let id = "UCabcdefghijklmnopqrstuv"; + let config = set_channel_tags(&path, &tags, "@another", id).unwrap(); + assert_eq!( + config.categories["Music"][1], + format!("https://www.youtube.com/channel/{id}") + ); + let saved = fs::read_to_string(&path).unwrap(); + for comment in [ + "# manual preferences", + "# keep me", + "# favorites", + "# channel note", + ] { + assert!(saved.contains(comment)); + } + assert_eq!( + saved.parse::().unwrap()["other"]["value"].as_integer(), + Some(42) + ); + + for malformed in [ + "[categories\n", + "categories = 3\n", + "[categories]\nMusic = [3]\n", + "[categories]\nMusic = ['https://example.com/@example']\n", + "[categories]\nMusic = []\n' Music ' = []\n", + "channels = 3\n", + "[channels]\n'@example' = 3\n", + "[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n", + "[channels]\n'@example' = '@another'\n", + "[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n", + "[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n", + ] { + fs::write(&path, malformed).unwrap(); + assert!(load(&path).is_err()); + assert!(set_channel_tags(&path, &tags, "@example", id).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), malformed); + } + fs::write(&path, manual).unwrap(); + for tags in [ + BTreeMap::from([(" ".to_owned(), true)]), + BTreeMap::from([("bad\nname".to_owned(), false)]), + BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]), + ] { + assert!(set_channel_tags(&path, &tags, "@example", id).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), manual); + } + assert!(set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), manual); + assert!(save(&path, Some(b"previous contents"), b"replacement").is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), manual); + assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1); +} + +#[test] +fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let manual = format!( + "[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n" + ); + fs::write(&path, manual).unwrap(); + let config = load(&path).unwrap(); + assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); + assert_eq!( + config.categories["Direct"], + ["https://www.youtube.com/@RustVideos"] + ); + let tags = BTreeMap::from([("Japanese".to_owned(), true)]); + let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap(); + assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url)); + assert_eq!(config.categories["Other"], std::slice::from_ref(&url)); + let saved = fs::read_to_string(&path).unwrap(); + let document = saved.parse::().unwrap(); + let entries = document["categories"]["Japanese"].as_array().unwrap(); + assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed"))); + assert_eq!(entries.len(), 2); + assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str())); + assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str())); + assert!(saved.contains("# keep this entry comment")); + set_channel_tags(&path, &tags, "@jawed", id).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), saved); + assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), saved); + assert_eq!(load(&path).unwrap().categories["Japanese"], [url]); +} + +#[test] +fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let other_id = "UCabcdefghijklmnopqrstuv"; + let other_url = format!("https://www.youtube.com/channel/{other_id}"); + let manual = format!( + "# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n" + ); + fs::write(&path, &manual).unwrap(); + let tags = BTreeMap::from([ + ("First".to_owned(), true), + ("Second".to_owned(), true), + ("New empty".to_owned(), false), + ]); + let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); + assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]); + assert_eq!( + config.categories["Second"], + [other_url.clone(), url.clone()] + ); + assert!(config.categories["New empty"].is_empty()); + let saved = fs::read_to_string(&path).unwrap(); + let document = saved.parse::().unwrap(); + let entries = document["categories"][" First "].as_array().unwrap(); + assert_eq!(entries.len(), 5); + assert!( + entries + .iter() + .skip(1) + .all(|entry| entry.as_str() == Some("@current")) + ); + assert_eq!( + document["categories"]["Omitted"] + .as_array() + .unwrap() + .get(0) + .unwrap() + .as_str(), + Some("@old") + ); + assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str())); + assert_eq!( + document["channels"]["@other"].as_str(), + Some(other_url.as_str()) + ); + + // Restore manually written aliases so deselection exercises every accepted spelling. + fs::write(&path, &manual).unwrap(); + let tags = BTreeMap::from([ + ("First".to_owned(), false), + ("Second".to_owned(), true), + ("New empty".to_owned(), false), + ]); + let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); + assert_eq!(config.categories["First"], std::slice::from_ref(&other_url)); + assert_eq!( + config.categories["Second"], + [other_url.clone(), url.clone()] + ); + assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url)); + assert!(config.categories["New empty"].is_empty()); + let saved = fs::read_to_string(&path).unwrap(); + for comment in [ + "# subscriptions", + "# old alias", + "# other mapping", + "# unrelated creator", + "# first tag", + "# untouched tag", + ] { + assert!(saved.contains(comment), "lost {comment}"); + } + let tags = BTreeMap::from([ + ("First".to_owned(), false), + ("Second".to_owned(), false), + ("Omitted".to_owned(), false), + ]); + set_channel_tags(&path, &tags, "@current", id).unwrap(); + let config = load(&path).unwrap(); + assert_eq!(config.categories["First"], std::slice::from_ref(&other_url)); + assert_eq!(config.categories["Second"], [other_url]); + assert!(config.categories["Omitted"].is_empty()); + assert!(config.categories["New empty"].is_empty()); + assert!( + fs::read_to_string(&path) + .unwrap() + .contains("# unrelated creator") + ); +} + +#[test] +fn saved_channels_use_readable_aliases_and_include_every_subscription_once() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let mapped_id = "UC4QobU6STFB0P71PMvOGN5A"; + let untagged_id = "UCabcdefghijklmnopqrstuv"; + let legacy_id = "UCzyxwvutsrqponmlkjihgfe"; + let mapped = format!("https://www.youtube.com/channel/{mapped_id}"); + let untagged = format!("https://www.youtube.com/channel/{untagged_id}"); + let legacy = format!("https://www.youtube.com/channel/{legacy_id}"); + fs::write( + &path, + format!( + "[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n" + ), + ) + .unwrap(); + let config = load(&path).unwrap(); + let expected = vec![ + ("@aAlias".to_owned(), mapped.clone()), + ("@untagged".to_owned(), untagged.clone()), + ( + "https://www.youtube.com/@manual".to_owned(), + "https://www.youtube.com/@manual".to_owned(), + ), + (legacy.clone(), legacy.clone()), + ]; + assert_eq!(config.saved_channels(), expected); + assert_eq!(load(&path).unwrap().saved_channels(), expected); + let mut identities: Vec<_> = config + .saved_channels() + .into_iter() + .map(|(_, identity)| identity) + .collect(); + identities.sort_unstable(); + assert_eq!(identities, config.subscriptions()); + assert!( + config + .categories + .values() + .flatten() + .all(|id| id != &untagged) + ); + assert_eq!(config.channels["@zAlias"], mapped); +} + +#[test] +fn saved_channels_support_empty_and_registry_only_configs() { + assert!(Config::default().saved_channels().is_empty()); + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"; + fs::write( + &path, + format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"), + ) + .unwrap(); + let config = load(&path).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!( + config.saved_channels(), + vec![("@aAlias".to_owned(), identity.to_owned())] + ); + assert_eq!(config.subscriptions(), [identity]); +} + +#[test] +fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let id = "UC4QobU6STFB0P71PMvOGN5A"; + let url = format!("https://www.youtube.com/channel/{id}"); + let other_id = "UCabcdefghijklmnopqrstuv"; + let other_url = format!("https://www.youtube.com/channel/{other_id}"); + let registry = format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n"); + fs::write(&path, ®istry).unwrap(); + let config = load(&path).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!(config.channels["@old"], url); + assert_eq!(config.subscriptions(), std::slice::from_ref(&url)); + + let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap(); + assert!(config.categories.is_empty()); + assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]); + assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions()); + + fs::write( + &path, + format!( + "{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n" + ), + ) + .unwrap(); + let expected = vec![ + "https://www.youtube.com/@RustVideos".to_owned(), + url.clone(), + other_url, + ]; + let config = load(&path).unwrap(); + assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url)); + assert_eq!(config.subscriptions(), expected); + let tags = BTreeMap::from([("Tagged".to_owned(), false)]); + let config = set_channel_tags(&path, &tags, "@current", id).unwrap(); + assert!(config.categories["Tagged"].is_empty()); + assert_eq!(config.subscriptions(), expected); + assert_eq!(load(&path).unwrap().subscriptions(), expected); +} + +#[test] +fn paths_and_channel_addresses_are_strict() { + let home = Some(OsString::from("/home/test")); + let fallback = PathBuf::from("/home/test/.config/tubular/config.toml"); + for xdg in [ + None, + Some(OsString::new()), + Some(OsString::from("relative")), + ] { + assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback); + } + assert_eq!( + resolve_path(Some("/config".into()), None).unwrap(), + PathBuf::from("/config/tubular/config.toml") + ); + assert!(resolve_path(None, None).is_err()); + assert!(resolve_path(None, Some(OsString::new())).is_err()); + for channel in [ + "", + "@", + "@bad/name", + "@bad?query", + "@bad#fragment", + "@bad\n", + "https://www.youtube.com.evil/@example", + "http://www.youtube.com/@example", + "https://www.youtube.com/channel/UCshort", + "channel/UCshort", + "https://www.youtube.com/@example/videos", + "https://www.youtube.com/@example?x=1", + "https://www.youtube.com/@example/../other", + "https://www.youtube.com/@example//", + ] { + assert!(normalize_channel(channel).is_err(), "accepted {channel:?}"); + } + assert_eq!( + normalize_channel("@example").unwrap(), + "https://www.youtube.com/@example" + ); + assert_eq!( + normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(), + "https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv" + ); + assert!(category_name(" \t ").is_err()); +} diff --git a/tests/unit/keys.rs b/tests/unit/keys.rs new file mode 100644 index 0000000..914aa6f --- /dev/null +++ b/tests/unit/keys.rs @@ -0,0 +1,559 @@ +use super::*; +use toml_edit::DocumentMut; + +fn configured(source: &str) -> io::Result { + let document = source.parse::().unwrap(); + Bindings::from_toml(document.get("keys")) +} + +fn press( + bindings: &Bindings, + context: Context, + code: KeyCode, + modifiers: KeyModifiers, +) -> Option { + bindings.resolve(context, KeyEvent::new(code, modifiers)) +} + +#[test] +fn only_section_headers_are_bold_in_the_grouped_overview() { + use ratatui::{ + buffer::Buffer, + layout::Rect, + style::Modifier, + widgets::{Paragraph, Widget, Wrap}, + }; + let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap(); + for width in [21, 64] { + let area = Rect::new(0, 0, width, 16); + let mut buffer = Buffer::empty(area); + Paragraph::new(bindings.help_text()) + .wrap(Wrap { trim: false }) + .render(area, &mut buffer); + assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD)); + for y in 2..area.height { + for x in 0..width { + assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); + } + } + let binding_visible = (2..area.height).any(|y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .contains("F24") + }); + assert!( + binding_visible, + "custom shortcut must remain visible after wrapping" + ); + } +} + +#[test] +fn contextual_cycle_and_direct_preview_bindings() { + let defaults = Bindings::default(); + for (context, action) in [ + (Context::Normal, Action::InfoOverview), + (Context::Info, Action::InfoDescription), + (Context::Description, Action::InfoOverview), + (Context::CollectionNormal, Action::InfoOverview), + (Context::CollectionInfo, Action::InfoDescription), + (Context::CollectionDescription, Action::InfoOverview), + ] { + assert_eq!( + press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE), + Some(action) + ); + } + let bindings = + configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n").unwrap(); + for context in VIEW { + assert_eq!( + press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE), + Some(Action::InfoOverview) + ); + assert_eq!( + press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE), + Some(Action::InfoDescription) + ); + } + assert_eq!( + press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE), + Some(Action::InfoHide) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('-'), + KeyModifiers::NONE + ), + Some(Action::ViewPrevious) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Down, + KeyModifiers::NONE + ), + Some(Action::DescriptionNext) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + Some(Action::VideoNext) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('j'), + KeyModifiers::CONTROL + ), + Some(Action::DescriptionNext) + ); + assert_eq!( + press( + &defaults, + Context::SearchInput, + KeyCode::Char('d'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &defaults, + Context::SelectorNormal, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + Some(Action::SelectorNext) + ); + assert_eq!( + press( + &defaults, + Context::SelectorInput, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + None + ); + for context in ALL { + assert_eq!( + press( + &defaults, + *context, + KeyCode::Char('c'), + KeyModifiers::CONTROL + ), + Some(Action::Interrupt) + ); + } +} + +#[test] +fn modifier_normalization_is_exact_and_allocation_free() { + let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap(); + for (code, modifiers) in [ + (KeyCode::Char('I'), KeyModifiers::NONE), + (KeyCode::Char('I'), KeyModifiers::SHIFT), + (KeyCode::Char('i'), KeyModifiers::SHIFT), + ] { + assert_eq!( + press(&bindings, Context::Normal, code, modifiers), + Some(Action::InfoOverview) + ); + } + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('i'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('I'), + KeyModifiers::ALT + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('?'), + KeyModifiers::SHIFT + ), + Some(Action::Help) + ); + for (code, modifiers) in [ + (KeyCode::BackTab, KeyModifiers::NONE), + (KeyCode::BackTab, KeyModifiers::SHIFT), + (KeyCode::Tab, KeyModifiers::SHIFT), + ] { + assert_eq!( + press(&bindings, Context::Normal, code, modifiers), + Some(Action::ViewTabPrevious) + ); + } + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('+'), + KeyModifiers::CONTROL + ), + Some(Action::ViewNext) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('+'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::F(24), + KeyModifiers::ALT + ), + Some(Action::ViewRefresh) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('界'), + KeyModifiers::SUPER + ), + Some(Action::SearchEdit) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ), + None + ); +} + +#[test] +fn aliases_replace_defaults_and_can_disable_actions() { + let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); + let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); + assert_eq!(quoted, bindings); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('r'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('q'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('x'), + KeyModifiers::NONE + ), + Some(Action::ViewRefresh) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('y'), + KeyModifiers::CONTROL + ), + Some(Action::ViewRefresh) + ); +} + +#[test] +fn collection_removal_defaults_are_scoped_and_inherit_view_actions() { + let bindings = Bindings::default(); + for &context in ALL { + for (key, action) in [ + ('x', Action::CollectionRemoveSelected), + ('X', Action::CollectionRemoveBulk), + ] { + assert_eq!( + press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), + COLLECTION.contains(&context).then_some(action), + ); + } + assert_eq!( + press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE), + None, + ); + } + for (ordinary, collection) in [ + (Context::Normal, Context::CollectionNormal), + (Context::Info, Context::CollectionInfo), + (Context::Description, Context::CollectionDescription), + ] { + for &(key, action) in &bindings.contexts[ordinary as usize] { + assert_eq!( + bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)), + Some(action), + "{ordinary:?} / {collection:?}: {key:?}", + ); + } + assert_eq!( + bindings.contexts[collection as usize].len(), + bindings.contexts[ordinary as usize].len() + 2, + ); + } +} + +#[test] +fn collection_overrides_aliases_normalize_and_disable() { + let bindings = configured( + "[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n", + ) + .unwrap(); + let quoted = configured( + "[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n", + ).unwrap(); + assert_eq!(bindings, quoted); + assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x"); + assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B"); + let help = bindings.help_text().to_string(); + assert!(help.contains("Alt+x")); + assert!(help.lines().any(|line| line.trim_start().starts_with("B "))); + for &context in ALL { + for (code, modifiers, action) in [ + ( + KeyCode::Char('x'), + KeyModifiers::ALT, + Action::CollectionRemoveSelected, + ), + ( + KeyCode::Char('b'), + KeyModifiers::SHIFT, + Action::CollectionRemoveBulk, + ), + ( + KeyCode::Char('B'), + KeyModifiers::NONE, + Action::CollectionRemoveBulk, + ), + ] { + assert_eq!( + press(&bindings, context, code, modifiers), + COLLECTION.contains(&context).then_some(action), + ); + } + for key in ['x', 'X'] { + assert_eq!( + press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE), + None + ); + } + } + let disabled = configured( + "[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n", + ).unwrap(); + for &context in ALL { + for key in ['x', 'X'] { + assert_eq!( + press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE), + VIEW.contains(&context).then_some(Action::ViewRefresh), + ); + } + } +} + +#[test] +fn collection_contexts_validate_inherited_binding_conflicts() { + for source in [ + "[keys]\nview.refresh = 'x'", + "[keys]\nview.watch_later.browse = 'X'", + "[keys]\nview.saved.browse = 'X'", + "[keys]\nview.collection.remove.selected = 'q'", + "[keys]\nview.collection.remove.bulk = 'down'", + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("Collection"), "{error}"); + } +} + +#[test] +fn collection_browse_shortcuts_are_distinct_and_view_scoped() { + let defaults = Bindings::default(); + let overrides = configured( + "[keys.view.watch_later]\ncategory = ['OpTiOn+b', 'alt+b']\n[keys]\n'view.saved.category' = []\n", + ) + .unwrap(); + for &context in ALL { + for (key, modifiers, action) in [ + ('W', KeyModifiers::NONE, Action::WatchLaterBrowse), + ('w', KeyModifiers::SHIFT, Action::WatchLaterBrowse), + ('V', KeyModifiers::NONE, Action::SavedVideoBrowse), + ('v', KeyModifiers::SHIFT, Action::SavedVideoBrowse), + ('w', KeyModifiers::ALT, Action::WatchLaterBrowseCategory), + ('v', KeyModifiers::ALT, Action::SavedVideoBrowseCategory), + ] { + assert_eq!( + press(&defaults, context, KeyCode::Char(key), modifiers), + VIEW.contains(&context).then_some(action), + ); + } + assert_eq!( + press(&overrides, context, KeyCode::Char('b'), KeyModifiers::ALT), + VIEW.contains(&context) + .then_some(Action::WatchLaterBrowseCategory), + ); + for key in ['w', 'v'] { + assert_eq!( + press(&overrides, context, KeyCode::Char(key), KeyModifiers::ALT), + None, + ); + } + for key in ['W', 'V'] { + assert_eq!( + press(&defaults, context, KeyCode::Char(key), KeyModifiers::ALT), + None, + ); + } + } + assert_eq!(overrides.label(Action::WatchLaterBrowseCategory), "Alt+b"); +} + +#[test] +fn collection_browsing_and_save_overrides_retain_contexts() { + let bindings = configured( + "[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\ncategory = []\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\ncategory = []\n[keys.view.collection.remove]\nbulk = []\n", + ).unwrap(); + for &context in ALL { + for (key, modifiers, action, enabled) in [ + ( + 'w', + KeyModifiers::ALT, + Action::WatchLaterSave, + VIEW.contains(&context), + ), + ( + 'v', + KeyModifiers::ALT, + Action::SavedVideoSave, + VIEW.contains(&context), + ), + ( + 'x', + KeyModifiers::SHIFT, + Action::WatchLaterBrowse, + VIEW.contains(&context), + ), + ( + 'f', + KeyModifiers::CONTROL, + Action::WatchLaterFilter, + matches!(context, Context::Normal | Context::CollectionNormal), + ), + ( + 'b', + KeyModifiers::ALT, + Action::SavedVideoBrowse, + VIEW.contains(&context), + ), + ] { + assert_eq!( + press(&bindings, context, KeyCode::Char(key), modifiers), + enabled.then_some(action) + ); + } + } +} + +#[test] +fn obsolete_removal_names_are_unknown() { + for name in [ + "view.watch_later.remove", + "video.watch_later.remove", + "video.saved.remove", + ] { + let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("unknown action"), "{error}"); + assert!(error.to_string().contains(name), "{error}"); + } +} + +#[test] +fn invalid_entries_and_collisions_have_actionable_errors() { + for (source, path) in [ + ("[keys]\nunknown = 'x'", "keys.unknown"), + ("[keys.unknown]", "keys.unknown"), + ("keys = []", "keys"), + ("[keys]\nview = 'x'", "keys.view"), + ("[keys]\nview.refresh = 1", "keys.view.refresh"), + ("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"), + ("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"), + ( + "[keys]\nview.refresh = 'ctrl+control+x'", + "keys.view.refresh", + ), + ("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"), + ("[keys]\nview.refresh = 'f25'", "keys.view.refresh"), + ("[keys]\nview.refresh = ''", "keys.view.refresh"), + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains(path), "{error}"); + } + for source in [ + "[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'", + "[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'", + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("keys.video.info.overview"), + "{error}" + ); + } + for source in [ + "[keys]\nview.refresh = 'q'", + "[keys]\nview.refresh = 'ctrl+c'", + "[keys]\nsearch.edit = 'shift+g'", + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + let collision = configured("[keys]\nview.refresh = 'q'") + .unwrap_err() + .to_string(); + assert!(collision.contains("view.refresh") && collision.contains("app.quit")); + // Disjoint contexts can use the same key, including an overridden input command. + assert!(configured("[keys]\ninput.submit = 'q'").is_ok()); +} diff --git a/tests/unit/miruro.rs b/tests/unit/miruro.rs new file mode 100644 index 0000000..52d07e3 --- /dev/null +++ b/tests/unit/miruro.rs @@ -0,0 +1,108 @@ +use super::*; + +#[test] +fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() { + let sources = serde_json::json!({ + "episodeNumber": 1, + "config": { "sun": { "player": "native", "proxy": false } }, + "tracks": [{ "track": "ssub", "providers": [{ + "provider": "sun", + "servers": [{ + "headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" }, + "streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }] + }], + "subtitles": [{ + "file": "https://vault.keeply.top/english.jpeg", "format": "vtt", + "language": "en", "label": "English", "default": true + }] + }] }] + }); + let identity = Identity::new("catalog".into(), 1).unwrap(); + let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap(); + assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity); + assert_ne!( + Identity::new("catalog".into(), 2).unwrap().key(), + source.identity.key() + ); + for key in [ + "miruro:catalog:01", + "miruro:catalog:0", + "miruro:catalog:2147483648", + "miruro:catalog/:1", + ] { + assert!(Identity::from_key(key).is_err()); + } + for (pointer, value) in [ + ("/episodeNumber", serde_json::json!(49)), + ("/config/sun/proxy", serde_json::json!({"segments": true})), + ( + "/tracks/0/providers/0/servers/0/streams/0/url", + serde_json::json!("file:///secret"), + ), + ( + "/tracks/0/providers/0/servers/0/streams/0/embed", + serde_json::json!({"url": "https://embed.example"}), + ), + ( + "/tracks/0/providers/0/servers/0/headers/origin", + serde_json::json!("secret"), + ), + ( + "/tracks/0/providers/0/servers/0/headers/Referer", + serde_json::json!("secret\r\nX-Injected: true"), + ), + ] { + let mut invalid_sources = sources.clone(); + if let Some(field) = invalid_sources.pointer_mut(pointer) { + *field = value; + } else { + invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = value; + } + let error = SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1") + .unwrap_err(); + assert!(!error.to_string().contains("secret")); + } + let mut invalid_source = source; + invalid_source + .subtitles + .push(invalid_source.subtitles[0].clone()); + assert!(invalid_source.validate().is_err()); + invalid_source.subtitles[1].default = false; + invalid_source.subtitles[1].format = "srt".into(); + assert!(invalid_source.validate().is_err()); +} + +#[test] +fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() { + let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#; + let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#; + let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#; + let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap(); + assert_eq!(detail.media["episode_count"], 12); + assert_eq!(detail.episodes[0]["episode_number"], 1); + assert_eq!(detail.episodes[0]["absolute_number"], 49); + assert_eq!(detail.episodes[0]["title"], "Locusts"); + assert!(detail.episodes[0]["canon_type"].is_null()); + for chunk in [ + "", // The promised episodes never arrive. + r#"{"type":"chunk","id":9,"error":["failed"]}"#, + r#"{"type":"chunk","id":9,"data":[[999]]}"#, + r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle. + r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#, + ] { + assert_eq!( + decode_detail(&format!("{initial}\n{chunk}")) + .err() + .unwrap() + .kind(), + io::ErrorKind::InvalidData + ); + } + assert_eq!( + decode_detail(r#"{"type":"redirect","location":"/"}"#) + .err() + .unwrap() + .kind(), + io::ErrorKind::NotFound + ); +} diff --git a/tests/unit/miruro_browser.rs b/tests/unit/miruro_browser.rs new file mode 100644 index 0000000..2b292cd --- /dev/null +++ b/tests/unit/miruro_browser.rs @@ -0,0 +1,69 @@ +use super::*; +#[test] +fn catalog_and_actual_episode_numbers_remain_distinct() { + let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null}); + let rows = catalog(vec![media.clone()]).unwrap(); + assert_eq!(rows[0].id, "opaque_ID"); + assert!(rows[0].metadata["episode_counts"]["sub"].is_null()); + assert!(catalog(vec![media.clone(), media.clone()]).is_err()); + let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500}); + let detail = || Detail { + media: media.clone(), + episodes: vec![episode.clone()], + }; + let data = series("opaque_ID", detail()).unwrap(); + assert_eq!(data.episodes.len(), 1); + assert_eq!(data.episodes[0].identity.episode, 3); + assert_eq!(data.episodes[0].identity.media_id, "opaque_ID"); + assert_eq!(data.episodes[0].duration_seconds, Some(1500)); + assert_eq!( + data.episodes[0].synopsis.as_deref(), + Some("Actual synopsis") + ); + assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none()); + assert!(series("other", detail()).is_err()); + assert!( + series( + "opaque_ID", + Detail { + media: media.clone(), + episodes: vec![episode.clone(), episode] + } + ) + .is_err() + ); + assert!( + series( + "opaque_ID", + Detail { + media, + episodes: vec![serde_json::json!({"absolute_number":49})] + } + ) + .is_err() + ); + assert!(slug("薬屋").is_err()); + assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title"); + assert_eq!(slug(" --Title-- ").unwrap(), "-title-"); + assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu"); + assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe"); + assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i"); + assert_eq!( + slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(), + "a-b-c" + ); + assert_eq!(slug("A\u{0085}B").unwrap(), "ab"); + assert!(slug("!!薬屋!!").is_err()); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(), + "AB" + ); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(), + "BC" + ); + assert_eq!( + title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(), + "" + ); +} diff --git a/tests/unit/miruro_ui.rs b/tests/unit/miruro_ui.rs new file mode 100644 index 0000000..94579ed --- /dev/null +++ b/tests/unit/miruro_ui.rs @@ -0,0 +1,683 @@ +use super::*; +use ratatui::crossterm::event::KeyModifiers; +use std::{ + collections::BTreeMap, + fs, + io::Write, + sync::atomic::{AtomicU64, Ordering}, +}; +use tubular::miruro::{Detail, SourceDocument}; + +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, + 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 source(video: &Video, token: &str) -> SourceDocument { + SourceDocument { + version: 1, + url: format!("https://vault.keeply.top/master.m3u8?token={token}"), + headers: BTreeMap::new(), + identity: Identity::from_key(&video.id).unwrap(), + title: Some(video.title.clone()), + subtitles: Vec::new(), + } +} +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))); +} +fn sources(directory: &Directory) -> Vec { + let path = directory.0.join("playback-sessions"); + if !path.exists() { + return Vec::new(); + } + fs::read_dir(path) + .unwrap() + .map(|entry| { + serde_json::from_slice(&fs::read(entry.unwrap().path().join("source.json")).unwrap()) + .unwrap() + }) + .collect() +} + +#[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_series_and_sources() { + 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))); + let video = browser.screen().selected_video().unwrap(); + browser.open(); + let stale = browser.generation; + browser.history(false); + browser.accept( + stale, + Ok(Response::Source( + video.clone(), + Box::new(source(&video, "stale")), + )), + ); + assert!(sources(&directory).is_empty()); + browser.history(true); + browser.open(); + let stale = browser.generation; + browser.refresh(); + browser.accept( + stale, + Ok(Response::Source( + video.clone(), + Box::new(source(&video, "refreshed-away")), + )), + ); + assert!(sources(&directory).is_empty()); +} + +#[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.open(); + assert!(browser.pending.is_some()); + browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE)); + assert!(browser.state.is_watched(&episode.id)); + assert_eq!(browser.screen().len(), 0); + assert!(browser.pending.is_none()); + 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.open(); + assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id)); + 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 + .miruro_session(&episode, &source(&episode, "progress")) + .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 selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() { + 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.open(); + let stale = browser.generation; + browser.select(Action::VideoNext); + browser.accept( + stale, + Ok(Response::Source( + first.clone(), + Box::new(source(&first, "not-selected")), + )), + ); + assert!(sources(&directory).is_empty()); + let selected = browser.screen().selected_video().unwrap(); + assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7); + browser.save(CollectionKind::Saved); + browser.collection(CollectionKind::Saved); + browser.open(); + browser.pending = None; + browser.accept( + browser.generation, + Ok(Response::Source( + selected.clone(), + Box::new(source(&first, "wrong-identity")), + )), + ); + assert!(sources(&directory).is_empty()); + browser.open(); + browser.pending = None; + let mut invalid = source(&selected, "private-token"); + invalid + .headers + .insert("X-Invalid".into(), "private-header\nvalue".into()); + browser.accept( + browser.generation, + Ok(Response::Source(selected.clone(), Box::new(invalid))), + ); + assert!(sources(&directory).is_empty()); + assert!(!browser.status.contains("private-token")); + assert!(!browser.status.contains("private-header")); + for token in ["fresh-one", "fresh-two"] { + browser.open(); + assert!( + matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title) + ); + browser.pending = None; // Deliver the real source response without a network request. + browser.accept( + browser.generation, + Ok(Response::Source( + selected.clone(), + Box::new(source(&selected, token)), + )), + ); + } + let written = sources(&directory); + assert_eq!(written.len(), 2); + assert!( + written + .iter() + .all(|source| source["identity"]["episode"] == 7 + && source["identity"]["media_id"] == "first") + ); + assert!( + written + .iter() + .any(|source| source["url"].as_str().unwrap().ends_with("fresh-one")) + ); + assert!( + written + .iter() + .any(|source| source["url"].as_str().unwrap().ends_with("fresh-two")) + ); + assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress. + assert!( + !browser + .state + .contains(CollectionKind::Watching, &selected.id) + ); + assert!(browser.screen().error.is_some()); // Native launch errors remain visible. +} + +#[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))); +} diff --git a/tests/unit/thumbnail.rs b/tests/unit/thumbnail.rs new file mode 100644 index 0000000..83ce5db --- /dev/null +++ b/tests/unit/thumbnail.rs @@ -0,0 +1,331 @@ +use super::*; + +#[test] +fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() { + use ratatui::{Terminal, backend::TestBackend}; + use std::time::Duration; + let mut thumbnail = Thumbnail::new(); + thumbnail.selected = Some("fixture".into()); + let area = Rect::new(2, 2, 80, 8); + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + let mut loading_width = 0; + terminal + .draw(|frame| loading_width = thumbnail.draw(frame, area)) + .unwrap(); + let (_decode_tx, decode_rx) = mpsc::channel(); + thumbnail.decoder = Some(decode_rx); + let mut decoding_width = 0; + terminal + .draw(|frame| decoding_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, decoding_width); + thumbnail.decoder = None; + let (requests_tx, requests_rx) = mpsc::channel::(); + let (replies_tx, replies_rx) = mpsc::channel(); + thumbnail.image = Some(ThumbnailImage { + id: "fixture".into(), + protocol: ThreadProtocol::new( + requests_tx, + Some( + thumbnail + .picker + .new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)), + ), + ), + replies: replies_rx, + complete: false, + }); + let mut preparing_width = 0; + terminal + .draw(|frame| preparing_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, preparing_width); + terminal + .draw(|frame| preparing_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, preparing_width); + replies_tx + .send(Ok(requests_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .resize_encode() + .unwrap())) + .unwrap(); + let mut ready_width = 0; + terminal + .draw(|frame| ready_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!( + loading_width, ready_width, + "right-hand content must not move when the image finishes loading" + ); + thumbnail.cancel(); + thumbnail.select("fixture"); + terminal + .draw(|frame| ready_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, ready_width); + replies_tx + .send(Err("fixture renderer failure".into())) + .unwrap(); + let mut error_width = 0; + terminal + .draw(|frame| error_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, error_width); + assert!(thumbnail.error.is_some()); +} + +#[test] +fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() { + use ratatui::{Terminal, backend::TestBackend}; + use std::time::Duration; + + let mut thumbnail = Thumbnail::new(); + let url = "https://example.test/cover.png"; + thumbnail.selected = Some(url.into()); + thumbnail.aspect = (2, 3); + let area = Rect::new(2, 2, 80, 8); + let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3)); + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + let mut loading_width = 0; + terminal + .draw(|frame| loading_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, slot.width); + // The artwork need not exactly match the placeholder's 2:3 aspect. + let protocol = thumbnail + .picker + .new_resize_protocol(image::DynamicImage::new_rgb8(60, 100)); + let (requests_tx, requests_rx) = mpsc::channel::(); + let (replies_tx, replies_rx) = mpsc::channel(); + thumbnail.image = Some(ThumbnailImage { + id: url.into(), + protocol: ThreadProtocol::new(requests_tx, Some(protocol)), + replies: replies_rx, + complete: false, + }); + terminal + .draw(|frame| { + assert_eq!(thumbnail.draw(frame, area), loading_width); + }) + .unwrap(); + let response = requests_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .resize_encode() + .unwrap(); + replies_tx.send(Ok(response)).unwrap(); + terminal + .draw(|frame| { + assert_eq!(thumbnail.draw(frame, area), loading_width); + }) + .unwrap(); + assert!(!thumbnail.is_loading()); + let size = thumbnail + .image + .as_ref() + .unwrap() + .protocol + .size_for(Resize::Scale(None), slot) + .unwrap(); + assert!(size.width <= slot.width && size.height <= slot.height); + let font = thumbnail.picker.font_size(); + let width = u32::from(size.width) * u32::from(font.width); + let height = u32::from(size.height) * u32::from(font.height); + assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height)); + thumbnail.cancel(); + thumbnail.select_url(url); + assert!(thumbnail.fetch.is_none()); + assert_eq!(thumbnail.selected.as_deref(), Some(url)); + terminal + .draw(|frame| { + assert_eq!(thumbnail.draw(frame, area), loading_width); + }) + .unwrap(); + thumbnail.clear_cache(); + assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty()); +} + +#[test] +fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() { + assert!(decode(b"not a JPEG".to_vec()).is_err()); + assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err()); + for format in [ + image::ImageFormat::Jpeg, + image::ImageFormat::Png, + image::ImageFormat::WebP, + ] { + let mut bytes = Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2, 3) + .write_to(&mut bytes, format) + .unwrap(); + let image = decode(bytes.into_inner()).unwrap(); + assert_eq!((image.width(), image.height()), (2, 3)); + } +} + +#[test] +fn cancellation_discards_queued_decode_completion() { + let mut thumbnail = Thumbnail::new(); + let (tx, rx) = mpsc::channel(); + let (requests_tx, _requests_rx) = mpsc::channel(); + let (_replies_tx, replies_rx) = mpsc::channel(); + let protocol = thumbnail + .picker + .new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)); + thumbnail.selected = Some("old-video".into()); + thumbnail.decoder = Some(rx); + assert!( + tx.send(Ok(ThumbnailImage { + id: "old-video".into(), + protocol: ThreadProtocol::new(requests_tx, Some(protocol)), + replies: replies_rx, + complete: false, + })) + .is_ok() + ); + thumbnail.cancel(); + thumbnail.selected = Some("new-video".into()); + thumbnail.poll(); + assert!(thumbnail.image.is_none()); + assert!(thumbnail.error.is_none()); +} + +#[test] +fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() { + let mut thumbnail = Thumbnail::new(); + let completed = |id: String| { + let (requests, _receiver) = mpsc::channel(); + let (_sender, replies) = mpsc::channel(); + ThumbnailImage { + id, + protocol: ThreadProtocol::new( + requests, + Some(Picker::halfblocks().new_resize_protocol(image::DynamicImage::new_rgb8(1, 1))), + ), + replies, + complete: true, + } + }; + for index in 0..CACHE_SIZE { + thumbnail.image = Some(completed(format!("video{index:06}"))); + thumbnail.cancel(); + } + thumbnail.image = Some(completed("video000008".into())); + thumbnail.selected = Some("video000008".into()); + // This is the oldest cached image: storing the outgoing image first would evict it. + thumbnail.select("video000000"); + assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000"); + assert!(thumbnail.fetch.is_none()); + thumbnail.select("video000001"); + assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001"); + assert!(thumbnail.fetch.is_none()); + thumbnail.cancel(); + assert!( + !thumbnail + .cached + .iter() + .any(|image| image.id == "video000002") + ); + assert!( + thumbnail + .cached + .iter() + .any(|image| image.id == "video000000") + ); + assert!( + thumbnail + .cached + .iter() + .any(|image| image.id == "video000008") + ); + thumbnail.clear_cache(); + assert!(thumbnail.cached.is_empty()); + assert!(thumbnail.image.is_none()); +} + +#[test] +fn small_thumbnail_fills_popup_proportionally_and_resizes() { + use ratatui::{Terminal, backend::TestBackend, style::Color}; + use std::time::Duration; + + let mut thumbnail = Thumbnail::new(); + let font = thumbnail.picker.font_size(); + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 48, + 36, + image::Rgb([240, 40, 70]), + )); + let (requests_tx, requests_rx) = mpsc::channel::(); + let (replies_tx, replies_rx) = mpsc::channel(); + thumbnail.selected = Some("thumbnail".into()); + thumbnail.image = Some(ThumbnailImage { + id: "thumbnail".into(), + protocol: ThreadProtocol::new( + requests_tx, + Some(thumbnail.picker.new_resize_protocol(image)), + ), + replies: replies_rx, + complete: false, + }); + let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); + for area in [ + Rect::new(3, 2, 80, 18), + Rect::new(7, 4, 30, 8), + Rect::new(3, 2, 9, 18), + Rect::new(7, 4, 30, 8), + ] { + let mut pending_width = 0; + terminal + .draw(|frame| { + pending_width = thumbnail.draw(frame, area); + }) + .unwrap(); + assert!(thumbnail.is_loading()); + assert!( + !terminal.backend().buffer().content.iter().any(|cell| { + matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) + }), + "a resize must not render stale pixels" + ); + let response = requests_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .resize_encode() + .unwrap(); + replies_tx.send(Ok(response)).unwrap(); + let mut occupied_width = 0; + terminal + .draw(|frame| { + occupied_width = thumbnail.draw(frame, area); + }) + .unwrap(); + assert!(!thumbnail.is_loading()); + assert_eq!(pending_width, occupied_width); + let buffer = terminal.backend().buffer(); + let pixels: Vec<_> = (0..40) + .flat_map(|y| (0..100).map(move |x| (x, y))) + .filter(|&(x, y)| { + let cell = &buffer[(x, y)]; + matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) + }) + .collect(); + let left = pixels.iter().map(|&(x, _)| x).min().unwrap(); + let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1; + let top = pixels.iter().map(|&(_, y)| y).min().unwrap(); + let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1; + assert!(left >= area.x && right <= area.right()); + assert_eq!(left, area.x); + assert_eq!(occupied_width, right - left); + assert!(top >= area.y && bottom <= area.bottom()); + assert!(right - left == area.width || bottom - top == area.height); + assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1); + let width = u32::from(right - left) * u32::from(font.width); + let height = u32::from(bottom - top) * u32::from(font.height); + assert!( + width.abs_diff(height * 4 / 3) + <= u32::from(font.width) + u32::from(font.height) * 4 / 3 + ); + } +} diff --git a/tests/unit/video_state.rs b/tests/unit/video_state.rs new file mode 100644 index 0000000..f2ef533 --- /dev/null +++ b/tests/unit/video_state.rs @@ -0,0 +1,1433 @@ +use super::*; + +struct TestDirectory(PathBuf); +impl TestDirectory { + fn new() -> Self { + loop { + let path = std::env::temp_dir().join(format!( + "tubular-watch-test-{}-{}", + std::process::id(), + NEXT_NAME.fetch_add(1, Ordering::Relaxed) + )); + match create_private_directory(&path) { + Ok(()) => return Self(path), + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, + Err(e) => panic!("{e}"), + } + } + } + fn config(&self) -> PathBuf { + self.0.join("config.toml") + } +} +impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn video() -> Video { + Video { + id: "jNQXAC9IVRw".into(), + title: "Me at the zoo".into(), + channel: Some("jawed".into()), + channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()), + uploader_id: Some("@jawed".into()), + timestamp: Some(1_114_300_000), + publication_date: Some("2005-04-23".into()), + availability: None, + } +} + +fn append(path: &Path, bytes: &[u8]) { + let mut options = OpenOptions::new(); + options.write(true).append(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).unwrap().write_all(bytes).unwrap(); +} + +fn record(id: &str, event: &str, eligible: bool, position: f64) -> String { + format!( + "{{\"version\":1,\"event\":\"{event}\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":{eligible}}}\n" + ) +} + +fn miruro_video(episode: u32) -> Video { + Video { + id: Identity::new("opaque_Show-1".into(), episode) + .unwrap() + .key(), + title: format!("Example show · Episode {episode}"), + channel: Some("Miruro".into()), + channel_id: None, + uploader_id: None, + timestamp: Some(1_114_300_000), + publication_date: Some("2005-04-23".into()), + availability: None, + } +} + +fn show() -> crate::miruro_browser::Media { + crate::miruro_browser::Media { + id: "opaque_Show-1".into(), + title: "Example show".into(), + metadata: serde_json::json!({ + "id": "opaque_Show-1", + "title": {"english": "Example show", "native": null}, + "episode_counts": {"sub": 12, "dub": null}, + "genres": ["Adventure", "Fantasy"], + }), + } +} + +#[test] +fn miruro_library_round_trips_overlapping_tags_and_keeps_episode_state_independent() { + let directory = TestDirectory::new(); + let path = directory.0.join("miruro-library.toml"); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.shows().is_empty()); + assert!(!path.exists()); + let episode = miruro_video(1); + for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { + state.save(kind, &episode).unwrap(); + } + state + .set_watch_status(&episode, WatchStatus::Watched) + .unwrap(); + let episode_files = ["saved-videos.toml", "watch-later.toml", "watch-state.toml"] + .map(|name| (name, fs::read(directory.0.join(name)).unwrap())); + let mut first = show(); + let mut second = show(); + second.id = "another_Show".into(); + second.title = "Another show".into(); + second.metadata = serde_json::Value::Null; + state + .save_show( + &first, + &[" Alpha ".into(), "Alpha".into(), "Beta".into()].into(), + ) + .unwrap(); + state + .save_show(&second, &["Beta".into(), "Gamma".into()].into()) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let mut state = State::open(&directory.config()).unwrap(); + assert_eq!(state.shows().len(), 2); + assert_eq!(state.shows()[0].media.metadata, first.metadata); + assert_eq!( + state.shows()[0].tags, + ["Alpha".into(), "Beta".into()].into() + ); + assert_eq!( + state.shows()[1].tags, + ["Beta".into(), "Gamma".into()].into() + ); + assert!(state.shows()[1].media.metadata.is_null()); + first.title = "Updated show title".into(); + first.metadata["title"]["english"] = serde_json::json!("Updated show title"); + state.save_show(&first, &BTreeSet::new()).unwrap(); + state.save_show(&first, &BTreeSet::new()).unwrap(); + let mut state = State::open(&directory.config()).unwrap(); + assert_eq!(state.shows().len(), 2); + assert_eq!(state.shows()[0].media.title, first.title); + assert_eq!(state.shows()[0].media.metadata, first.metadata); + assert!(state.shows()[0].tags.is_empty()); + state.remove_show(&first.id).unwrap(); + state.remove_show(&first.id).unwrap(); + let state = State::open(&directory.config()).unwrap(); + assert_eq!(state.shows().len(), 1); + assert_eq!(state.shows()[0].media.id, second.id); + assert!(state.is_watched(&episode.id)); + for (name, bytes) in episode_files { + assert_eq!(fs::read(directory.0.join(name)).unwrap(), bytes); + } + // Duplicate hand-edited records behave like the existing episode collections. + let document = fs::read_to_string(&path).unwrap(); + let duplicate = document.split_once("[[shows]]").unwrap().1; + append(&path, format!("\n[[shows]]{duplicate}").as_bytes()); + let mut state = State::open(&directory.config()).unwrap(); + assert_eq!(state.shows().len(), 1); + state.save_show(&second, &BTreeSet::new()).unwrap(); + assert_eq!(State::open(&directory.config()).unwrap().shows().len(), 1); + state.remove_show(&second.id).unwrap(); + assert!(State::open(&directory.config()).unwrap().shows().is_empty()); +} + +#[test] +fn miruro_library_rejects_invalid_inputs_and_files_without_changing_saved_state() { + let directory = TestDirectory::new(); + let path = directory.0.join("miruro-library.toml"); + let mut state = State::open(&directory.config()).unwrap(); + let media = show(); + state + .save_show(&media, &["Original".into()].into()) + .unwrap(); + let original = fs::read(&path).unwrap(); + for (field, value) in [ + ("id", "https://unsafe.example/show".to_owned()), + ("title", " ".to_owned()), + ("title", "bad\nshow".to_owned()), + ("title", "s".repeat(MAX_LINE + 1)), + ("metadata", "m".repeat(MAX_SHOW_METADATA_BYTES)), + ] { + let mut invalid_media = media.clone(); + match field { + "id" => invalid_media.id = value, + "title" => invalid_media.title = value, + _ => invalid_media.metadata = serde_json::Value::String(value), + } + assert!(state.save_show(&invalid_media, &BTreeSet::new()).is_err()); + assert_eq!(state.shows()[0].media.title, media.title); + assert_eq!(state.shows()[0].media.metadata, media.metadata); + assert_eq!(fs::read(&path).unwrap(), original); + } + for tags in [ + [" ".into()].into(), + ["bad\ttag".into()].into(), + ["t".repeat(MAX_TAG_BYTES + 1)].into(), + (0..=MAX_SHOW_TAGS).map(|index| index.to_string()).collect(), + ] { + assert!(state.save_show(&media, &tags).is_err()); + assert_eq!(state.shows()[0].tags, ["Original".into()].into()); + assert_eq!(fs::read(&path).unwrap(), original); + } + assert!(state.remove_show("https://unsafe.example/show").is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + let valid = std::str::from_utf8(&original).unwrap(); + let mut invalid_files = vec![ + "not = [valid".to_owned(), + "version = 2\nshows = []".to_owned(), + "version = 1\nunexpected = true".to_owned(), + "version = 1\nshows = [1]".to_owned(), + "version = 1\n[[shows]]\nid = 'stable'\ntitle = 'Title'\nmetadata = '{'\ntags = []" + .to_owned(), + ]; + let mut invalid_tags = Array::new(); + invalid_tags.push(1); + let mut blank_tag = Array::new(); + blank_tag.push(" "); + let mut excessive_tags = Array::new(); + for _ in 0..=MAX_SHOW_TAGS { + excessive_tags.push("Tag"); + } + for (field, item) in [ + ("tags", value(invalid_tags)), + ("tags", value(blank_tag)), + ("tags", value(excessive_tags)), + ("id", value("../unsafe")), + ("title", value("")), + ("title", value("s".repeat(MAX_LINE + 1))), + ( + "metadata", + value(format!("\"{}\"", "m".repeat(MAX_SHOW_METADATA_BYTES))), + ), + ("unknown", value(true)), + ] { + let mut document = valid.parse::().unwrap(); + document["shows"] + .as_array_of_tables_mut() + .unwrap() + .get_mut(0) + .unwrap()[field] = item; + invalid_files.push(document.to_string()); + } + for contents in invalid_files { + fs::write(&path, &contents).unwrap(); + assert!(State::open(&directory.config()).is_err(), "{contents}"); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); + } +} + +#[test] +fn miruro_library_does_not_overwrite_external_creations_or_edits() { + let directory = TestDirectory::new(); + let path = directory.0.join("miruro-library.toml"); + let mut state = State::open(&directory.config()).unwrap(); + let media = show(); + fs::write(&path, "external creation").unwrap(); + assert!(state.save_show(&media, &BTreeSet::new()).is_err()); + assert!(state.shows().is_empty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "external creation"); + fs::remove_file(&path).unwrap(); + state + .save_show(&media, &["Original".into()].into()) + .unwrap(); + append(&path, b"\n# external edit\n"); + let changed = fs::read(&path).unwrap(); + let mut updated = media.clone(); + updated.title = "Replacement".into(); + assert!(state.save_show(&updated, &["New".into()].into()).is_err()); + assert!(state.remove_show(&media.id).is_err()); + assert!(state.remove_show("missing_Show").is_err()); + assert_eq!(state.shows().len(), 1); + assert_eq!(state.shows()[0].media.title, media.title); + assert_eq!(state.shows()[0].tags, ["Original".into()].into()); + assert_eq!(fs::read(&path).unwrap(), changed); +} + +fn miruro_source(video: &Video) -> SourceDocument { + SourceDocument { + version: 1, + url: "https://cdn.example/video.m3u8?token=private-token".into(), + headers: [("Referer".into(), "https://player.example/".into())].into(), + identity: Identity::from_key(&video.id).unwrap(), + title: Some(video.title.clone()), + subtitles: Vec::new(), + } +} + +fn miruro_record( + video: &Video, + event: &str, + eligible: bool, + position: f64, + duration: Option, +) -> String { + serde_json::json!({ + "version": 2, + "event": event, + "media_identity": Identity::from_key(&video.id).unwrap(), + "position_seconds": position, + "duration_seconds": duration, + "completion_eligible": eligible, + }) + .to_string() + + "\n" +} + +#[test] +fn manual_status_transitions_preserve_both_providers_and_other_collections() { + for video in [video(), miruro_video(1)] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let other = miruro_video(2); + state + .set_watch_status(&other, WatchStatus::Watched) + .unwrap(); + for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { + state.save(kind, &video).unwrap(); + } + let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap(); + let later = fs::read(directory.0.join("watch-later.toml")).unwrap(); + for status in [ + WatchStatus::Watching, + WatchStatus::Watched, + WatchStatus::Watching, + ] { + state.set_watch_status(&video, status).unwrap(); + let watching = fs::read(directory.0.join("currently-watching.toml")).unwrap(); + let watched = + read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(); + state.set_watch_status(&video, status).unwrap(); + assert_eq!( + fs::read(directory.0.join("currently-watching.toml")).unwrap(), + watching + ); + assert_eq!( + read_bytes(&directory.0.join("watch-state.toml"), MAX_STATE_BYTES).unwrap(), + watched + ); + state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&other.id)); + assert_eq!( + state.is_watched(&video.id), + matches!(status, WatchStatus::Watched) + ); + assert_eq!( + state.contains(CollectionKind::Watching, &video.id), + matches!(status, WatchStatus::Watching) + ); + if matches!(status, WatchStatus::Watching) { + assert_eq!( + serde_json::to_value(&state.videos(CollectionKind::Watching)[0]).unwrap(), + serde_json::to_value(&video).unwrap() + ); + } + assert_eq!( + fs::read(directory.0.join("saved-videos.toml")).unwrap(), + saved + ); + assert_eq!( + fs::read(directory.0.join("watch-later.toml")).unwrap(), + later + ); + } + } +} + +#[test] +fn manual_watching_ignores_old_completion_replays_but_accepts_new_sessions() { + for video in [video(), miruro_video(1)] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let completion = if video.id.starts_with("miruro:") { + miruro_record(&video, "ended", true, 95.0, Some(100.0)) + } else { + record(&video.id, "ended", true, 95.0) + }; + let first = state.progress_file(&video).unwrap(); + register_identity(&first, &video.id).unwrap(); + append(&first, completion.as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + let second = state.progress_file(&video).unwrap(); + register_identity(&second, &video.id).unwrap(); + // Neither the reports nor the second session have been read by this instance. + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + state + .set_watch_status(&video, WatchStatus::Watching) + .unwrap(); + for report in [&first, &second] { + append(report, completion.as_bytes()); + } + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + assert!(!state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Watching, &video.id)); + let new_session = state.progress_file(&video).unwrap(); + register_identity(&new_session, &video.id).unwrap(); + append(&new_session, completion.as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(!state.contains(CollectionKind::Watching, &video.id)); + } +} + +#[test] +fn manual_status_rejects_invalid_videos_and_external_edits_before_writing() { + for video in [video(), miruro_video(1)] { + for status in [WatchStatus::Watching, WatchStatus::Watched] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let mut invalid_video = video.clone(); + invalid_video.id = "--invalid".into(); + assert!(state.set_watch_status(&invalid_video, status).is_err()); + assert!(!directory.0.join("watch-state.toml").exists()); + assert!(!directory.0.join("currently-watching.toml").exists()); + state + .set_watch_status(&video, WatchStatus::Watching) + .unwrap(); + state + .set_watch_status(&video, WatchStatus::Watched) + .unwrap(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + for name in ["watch-state.toml", "currently-watching.toml"] { + let path = directory.0.join(name); + let original = fs::read(&path).unwrap(); + append(&path, b"\n# external edit\n"); + let changed = fs::read(&path).unwrap(); + assert!(state.set_watch_status(&video, status).is_err()); + assert_eq!(fs::read(&path).unwrap(), changed); + assert!(state.is_watched(&video.id)); + assert!(!state.contains(CollectionKind::Watching, &video.id)); + assert!(!report.with_file_name("watching-seen.toml").exists()); + fs::write(path, original).unwrap(); + } + } + } +} + +#[test] +fn manual_watching_clears_consumed_pending_completion_and_checks_session_edits() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); + let watched = directory.0.join("watch-state.toml"); + fs::write(&watched, "external edit").unwrap(); + assert!(state.poll_progress().is_err()); + assert!(state.pending.contains(&video.id)); + fs::remove_file(watched).unwrap(); + let marker = report.with_file_name("watching-seen.toml"); + fs::write(&marker, "external edit").unwrap(); + assert!( + state + .set_watch_status(&video, WatchStatus::Watching) + .is_err() + ); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); + fs::remove_file(&marker).unwrap(); + state + .set_watch_status(&video, WatchStatus::Watching) + .unwrap(); + assert!(!state.pending.contains(&video.id)); + state.poll_progress().unwrap(); + assert!(!state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Watching, &video.id)); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + assert!(!state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Watching, &video.id)); +} + +#[test] +fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + state.save(kind, &video).unwrap(); + state.save(kind, &video).unwrap(); + assert_eq!(state.videos(kind).len(), 1); + assert!(state.contains(kind, &video.id)); + } + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + append( + &report, + record(&video.id, "progress", true, 95.0).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(!state.poll_progress().unwrap()); + let mut restarted = State::open(&directory.config()).unwrap(); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + let stored = &restarted.videos(kind)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, video.channel); + assert_eq!(stored.channel_id, video.channel_id); + assert_eq!(stored.uploader_id, video.uploader_id); + assert_eq!(stored.timestamp, video.timestamp); + assert_eq!(stored.publication_date, video.publication_date); + } + assert!(restarted.is_watched(&video.id)); + restarted + .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) + .unwrap(); + restarted + .remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id)) + .unwrap(); + let mut restarted = State::open(&directory.config()).unwrap(); + assert!(restarted.videos(CollectionKind::WatchLater).is_empty()); + assert!(restarted.contains(CollectionKind::Saved, &video.id)); + assert!(restarted.is_watched(&video.id)); + restarted.save(CollectionKind::WatchLater, &video).unwrap(); + restarted + .remove(CollectionKind::Saved, std::slice::from_ref(&video.id)) + .unwrap(); + let mut restarted = State::open(&directory.config()).unwrap(); + assert!(restarted.videos(CollectionKind::Saved).is_empty()); + assert!(restarted.contains(CollectionKind::WatchLater, &video.id)); + assert!(restarted.is_watched(&video.id)); + assert!(!restarted.poll_progress().unwrap()); +} + +#[test] +fn malformed_and_externally_changed_state_is_never_overwritten() { + let directory = TestDirectory::new(); + let path = directory.0.join("watch-later.toml"); + let mut state = State::open(&directory.config()).unwrap(); + fs::write(&path, "not = [valid").unwrap(); + assert!(state.save(CollectionKind::WatchLater, &video()).is_err()); + assert!(state.videos(CollectionKind::WatchLater).is_empty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid"); + assert!(State::open(&directory.config()).is_err()); + fs::remove_file(&path).unwrap(); + let saved_path = directory.0.join("saved-videos.toml"); + fs::write(&saved_path, "not = [valid").unwrap(); + assert!(state.save(CollectionKind::Saved, &video()).is_err()); + assert!(state.videos(CollectionKind::Saved).is_empty()); + assert_eq!(fs::read_to_string(&saved_path).unwrap(), "not = [valid"); + assert!(State::open(&directory.config()).is_err()); + fs::remove_file(&saved_path).unwrap(); + fs::write(&saved_path, "version = 1\n[[videos]]\nid = '--unsafe'\n").unwrap(); + assert!(State::open(&directory.config()).is_err()); + assert_eq!( + fs::read_to_string(&saved_path).unwrap(), + "version = 1\n[[videos]]\nid = '--unsafe'\n" + ); + fs::remove_file(&saved_path).unwrap(); + fs::write( + directory.0.join("watch-state.toml"), + "version = 1\nwatched = [\"--unsafe\"]\n", + ) + .unwrap(); + assert!(State::open(&directory.config()).is_err()); + assert_eq!( + fs::read_to_string(directory.0.join("watch-state.toml")).unwrap(), + "version = 1\nwatched = [\"--unsafe\"]\n" + ); +} + +#[test] +fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let report = state.progress_file(&video()).unwrap(); + register_identity(&report, &video().id).unwrap(); + append( + &report, + record(&video().id, "progress", true, 95.0).as_bytes(), + ); + let path = directory.0.join("watch-state.toml"); + fs::write(&path, "malformed state").unwrap(); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video().id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "malformed state"); + fs::remove_file(&path).unwrap(); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video().id)); + assert!(!state.poll_progress().unwrap()); +} + +#[test] +fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let report = state.progress_file(&video()).unwrap(); + register_identity(&report, &video().id).unwrap(); + let line = record(&video().id, "ended", true, 95.0); + append(&report, &line.as_bytes()[..line.len() - 1]); + assert!(!state.poll_progress().unwrap()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + let mut other = video(); + other.id = "rQ_J9WH6CGk".into(); + let bad = state.progress_file(&other).unwrap(); + register_identity(&bad, &other.id).unwrap(); + append(&bad, b"not json\n"); + append(&report, b"\n"); + assert!(state.poll_progress().is_err()); + assert!(state.is_watched(&video().id)); + assert!(state.poll_progress().unwrap()); + assert!(!state.poll_progress().unwrap()); +} + +#[test] +fn wrong_identity_ineligible_positions_and_untrusted_reports_do_not_mark_watched() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let report = state.progress_file(&video()).unwrap(); + register_identity(&report, &video().id).unwrap(); + for line in [ + record("rQ_J9WH6CGk", "progress", true, 100.0), + record(&video().id, "progress", false, 100.0), + record(&video().id, "closed", true, 94.9), + ] { + append(&report, line.as_bytes()); + } + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video().id)); + assert!(!state.is_watched(&video().id)); + assert!(!state.is_watched("rQ_J9WH6CGk")); + for line in [ + record(&video().id, "unknown", true, 100.0), + record(&video().id, "progress", true, -1.0), + record(&video().id, "progress", true, 100.0).replace("\"version\":1", "\"version\":2"), + record(&video().id, "progress", true, 100.0) + .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), + ] { + append(&report, line.as_bytes()); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video().id)); + assert!(!state.poll_progress().unwrap()); + } + #[cfg(unix)] + { + let symlink = state.progress_file(&video()).unwrap(); + register_identity(&symlink, &video().id).unwrap(); + let external = directory.0.join("external.jsonl"); + append( + &external, + record(&video().id, "progress", true, 100.0).as_bytes(), + ); + std::os::unix::fs::symlink(&external, &symlink).unwrap(); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video().id)); + assert!(!state.poll_progress().unwrap()); + } +} + +#[test] +fn watching_requires_a_valid_positive_bound_acknowledgement() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.poll_progress().unwrap()); + register_identity(&report, &video.id).unwrap(); + assert!(!state.poll_progress().unwrap()); + for line in [ + record("rQ_J9WH6CGk", "progress", true, 100.0), + record(&video.id, "progress", true, 0.0), + ] { + append(&report, line.as_bytes()); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } + for line in [ + record(&video.id, "unknown", true, 10.0), + record(&video.id, "progress", true, -1.0), + record(&video.id, "progress", true, 10.0) + .replace("\"duration_seconds\":100", "\"duration_seconds\":0"), + record(&video.id, "progress", true, 10.0).replace("\"version\":1", "\"version\":2"), + ] { + append(&report, line.as_bytes()); + assert!(state.poll_progress().is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + } + append( + &report, + record(&video.id, "progress", false, 10.0).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.is_watched(&video.id)); +} + +#[test] +fn starts_arriving_while_closed_preserve_metadata_without_a_creator() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let mut video = video(); + video.channel = None; + video.channel_id = None; + video.uploader_id = None; + assert!(state.save(CollectionKind::Saved, &video).is_err()); + assert!(state.save(CollectionKind::WatchLater, &video).is_err()); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + drop(state); + append( + &report, + record(&video.id, "closed", true, 10.0) + .replace("\"duration_seconds\":100", "\"duration_seconds\":null") + .as_bytes(), + ); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().unwrap()); + let stored = &state.videos(CollectionKind::Watching)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, None); + assert_eq!(stored.channel_id, None); + assert_eq!(stored.uploader_id, None); + assert_eq!(stored.publication_date, video.publication_date); + assert_eq!(stored.timestamp, video.timestamp); + append(&report, record(&video.id, "ended", false, 100.0).as_bytes()); + assert!(!state.poll_progress().unwrap()); + assert!(!state.is_watched(&video.id)); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.poll_progress().unwrap()); +} + +#[test] +fn completion_wins_over_parallel_sessions_late_reports_and_replays() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { + state.save(kind, &video).unwrap(); + } + let first = state.progress_file(&video).unwrap(); + register_identity(&first, &video.id).unwrap(); + append(&first, record(&video.id, "progress", true, 94.9).as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.is_watched(&video.id)); + let second = state.progress_file(&video).unwrap(); + register_identity(&second, &video.id).unwrap(); + append( + &second, + record(&video.id, "progress", true, 20.0).as_bytes(), + ); + append(&first, record(&video.id, "ended", true, 95.0).as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(!state.contains(CollectionKind::Watching, &video.id)); + for report in [&first, &second] { + append(report, record(&video.id, "progress", true, 30.0).as_bytes()); + } + let replay = state.progress_file(&video).unwrap(); + register_identity(&replay, &video.id).unwrap(); + append( + &replay, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + state + .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) + .unwrap(); + assert!(state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Saved, &video.id)); + assert!(state.contains(CollectionKind::WatchLater, &video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); +} + +#[test] +fn manual_removal_suppresses_existing_sessions_across_restart_but_not_new_sessions() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + for kind in [CollectionKind::Saved, CollectionKind::WatchLater] { + state.save(kind, &video).unwrap(); + } + let first = state.progress_file(&video).unwrap(); + register_identity(&first, &video.id).unwrap(); + append(&first, record(&video.id, "progress", true, 10.0).as_bytes()); + assert!(state.poll_progress().unwrap()); + // Simulate a crash after collection persistence but before its admission marker. + fs::remove_file(first.with_file_name("watching-seen.toml")).unwrap(); + let second = state.progress_file(&video).unwrap(); + register_identity(&second, &video.id).unwrap(); + append( + &second, + record(&video.id, "progress", true, 20.0).as_bytes(), + ); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + state + .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) + .unwrap(); + for report in [&first, &second] { + append(report, record(&video.id, "progress", true, 50.0).as_bytes()); + } + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Saved, &video.id)); + assert!(state.contains(CollectionKind::WatchLater, &video.id)); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + let replay = state.progress_file(&video).unwrap(); + register_identity(&replay, &video.id).unwrap(); + append( + &replay, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.is_watched(&video.id)); +} + +#[test] +fn watching_write_conflicts_retry_consumed_starts() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + let path = directory.0.join("currently-watching.toml"); + fs::write(&path, "external edit").unwrap(); + append( + &report, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert_eq!(fs::read_to_string(&path).unwrap(), "external edit"); + assert!(state.poll_progress().is_err()); + fs::remove_file(&path).unwrap(); + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + let original = fs::read(&path).unwrap(); + append(&path, b"\n# external removal conflict\n"); + let replay = state.progress_file(&video).unwrap(); + register_identity(&replay, &video.id).unwrap(); + append( + &replay, + record(&video.id, "progress", true, 20.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(!replay.with_file_name("watching-seen.toml").exists()); + fs::write(&path, original).unwrap(); + assert!(!state.poll_progress().unwrap()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.poll_progress().unwrap()); +} + +#[test] +fn failed_session_admission_retries_without_losing_starts_or_allowing_readdition() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + state.streams[0].prepare().unwrap(); + let marker = report.with_file_name("watching-seen.toml"); + append(&marker, b"external edit"); + append( + &report, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!( + state + .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) + .is_err() + ); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert_eq!(fs::read_to_string(&marker).unwrap(), "external edit"); + fs::remove_file(&marker).unwrap(); + assert!(state.poll_progress().unwrap()); + state + .remove(CollectionKind::Watching, std::slice::from_ref(&video.id)) + .unwrap(); + append( + &report, + record(&video.id, "progress", true, 50.0).as_bytes(), + ); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); +} + +#[test] +fn failed_completion_cleanup_retries_and_restart_prunes_stale_watching() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + append( + &report, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + let path = directory.0.join("currently-watching.toml"); + let original = fs::read(&path).unwrap(); + append(&path, b"\n# external edit\n"); + let changed = fs::read(&path).unwrap(); + append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); + assert!(state.poll_progress().is_err()); + assert!(state.is_watched(&video.id)); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert_eq!(fs::read(&path).unwrap(), changed); + append( + &report, + record(&video.id, "progress", true, 20.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + fs::write(&path, original).unwrap(); + assert!(state.poll_progress().unwrap()); + assert!(!state.contains(CollectionKind::Watching, &video.id)); + drop(state); + // Crash recovery must also handle a watched write committed before collection cleanup. + fs::write(&path, video_document(std::iter::once(&video)).to_string()).unwrap(); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.poll_progress().unwrap()); +} + +#[test] +fn legacy_sessions_use_saved_metadata_when_available_and_still_mark_completion_without_it() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + state.save(CollectionKind::Saved, &video).unwrap(); + let report = state.progress_file(&video).unwrap(); + fs::remove_file(report.with_file_name("video.toml")).unwrap(); + register_identity(&report, &video.id).unwrap(); + append( + &report, + record(&video.id, "progress", true, 10.0).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.contains(CollectionKind::Watching, &video.id)); + append(&report, record(&video.id, "ended", true, 95.0).as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + let mut other = video.clone(); + other.id = "rQ_J9WH6CGk".into(); + let report = state.progress_file(&other).unwrap(); + fs::remove_file(report.with_file_name("video.toml")).unwrap(); + register_identity(&report, &other.id).unwrap(); + append( + &report, + record(&other.id, "progress", true, 10.0).as_bytes(), + ); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + append(&report, record(&other.id, "closed", true, 95.0).as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&other.id)); +} + +#[test] +fn session_metadata_and_admission_markers_must_match_the_bound_identity() { + for mismatched_snapshot in [true, false] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + if mismatched_snapshot { + let mut other = video.clone(); + other.id = "rQ_J9WH6CGk".into(); + fs::write( + report.with_file_name("video.toml"), + video_document(std::iter::once(&other)).to_string(), + ) + .unwrap(); + } else { + append( + &report.with_file_name("watching-seen.toml"), + b"version = 1\nid = \"rQ_J9WH6CGk\"\n", + ); + } + append(&report, record(&video.id, "ended", true, 100.0).as_bytes()); + assert!(state.poll_progress().is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + } +} + +#[cfg(unix)] +#[test] +fn session_snapshots_reject_symlinks_and_shared_permissions() { + use std::os::unix::fs::PermissionsExt; + for symlink in [true, false] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + let snapshot = report.with_file_name("video.toml"); + if symlink { + let external = directory.0.join("external.toml"); + fs::write(&external, fs::read(&snapshot).unwrap()).unwrap(); + fs::remove_file(&snapshot).unwrap(); + std::os::unix::fs::symlink(&external, &snapshot).unwrap(); + } else { + fs::set_permissions(&snapshot, fs::Permissions::from_mode(0o644)).unwrap(); + } + append( + &report, + record(&video.id, "progress", true, 100.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + } +} + +#[test] +fn watching_and_session_snapshots_keep_date_handle_channel_and_schema_validation() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + for invalid_field in ["date", "handle", "channel"] { + let mut video = video(); + match invalid_field { + "date" => video.publication_date = Some("2005-02-30".into()), + "handle" => video.uploader_id = Some("not-a-handle".into()), + "channel" => video.channel_id = Some("not-a-channel-id".into()), + _ => unreachable!(), + } + assert!(state.progress_file(&video).is_err()); + assert!(state.save(CollectionKind::Watching, &video).is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } + let path = directory.0.join("currently-watching.toml"); + let contents = "version = 1\nvideos = []\nunexpected = true\n"; + fs::write(&path, contents).unwrap(); + assert!(State::open(&directory.config()).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), contents); +} + +#[test] +fn watching_and_session_snapshots_reject_oversized_files() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + OpenOptions::new() + .write(true) + .open(report.with_file_name("video.toml")) + .unwrap() + .set_len(MAX_STATE_BYTES + 1) + .unwrap(); + append( + &report, + record(&video.id, "progress", true, 100.0).as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + File::create(directory.0.join("currently-watching.toml")) + .unwrap() + .set_len(MAX_STATE_BYTES + 1) + .unwrap(); + assert!(State::open(&directory.config()).is_err()); +} + +#[test] +fn miruro_sessions_recover_collections_and_watching_without_persisting_sources_in_state() { + let directory = TestDirectory::new(); + let video = miruro_video(1); + let mut state = State::open(&directory.config()).unwrap(); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + state.save(kind, &video).unwrap(); + } + let source = miruro_source(&video); + let (source_file, report) = state.miruro_session(&video, &source).unwrap(); + let source_bytes = fs::read(&source_file).unwrap(); + assert_eq!(source_bytes, serde_json::to_vec(&source).unwrap()); + assert!(!report.exists()); + assert_eq!( + fs::read_to_string(report.with_file_name("video-id")).unwrap(), + video.id + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [ + &source_file, + &report.with_file_name("video-id"), + &report.with_file_name("video.toml"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + for path in [ + report.parent().unwrap(), + &directory.0.join("playback-sessions"), + ] { + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + // A GUI can acknowledge its final snapshot after Tubular has quit. + append( + &report, + miruro_record(&video, "closed", false, 10.0, None).as_bytes(), + ); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().unwrap()); + let stored = &state.videos(CollectionKind::Watching)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, video.channel); + assert_eq!(stored.timestamp, video.timestamp); + assert_eq!(stored.publication_date, video.publication_date); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.contains(CollectionKind::Watching, &video.id)); + assert!(!state.poll_progress().unwrap()); + append( + &report, + miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); + assert!(!state.poll_progress().unwrap()); + for kind in [CollectionKind::WatchLater, CollectionKind::Saved] { + let stored = &state.videos(kind)[0]; + assert_eq!(stored.id, video.id); + assert_eq!(stored.title, video.title); + assert_eq!(stored.channel, video.channel); + assert_eq!(stored.channel_id, None); + assert_eq!(stored.uploader_id, None); + assert_eq!(stored.timestamp, video.timestamp); + assert_eq!(stored.publication_date, video.publication_date); + } + for path in [ + directory.0.join("watch-later.toml"), + directory.0.join("saved-videos.toml"), + directory.0.join("currently-watching.toml"), + directory.0.join("watch-state.toml"), + report.with_file_name("video.toml"), + ] { + let contents = fs::read_to_string(path).unwrap(); + assert!(!contents.contains("private-token")); + assert!(!contents.contains("cdn.example")); + } + assert_eq!(fs::read(source_file).unwrap(), source_bytes); + assert!(report.exists()); +} + +#[test] +fn miruro_v2_requires_matching_episode_eligible_known_duration_and_threshold() { + for event in ["progress", "ended", "closed"] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + append( + &report, + miruro_record(&miruro_video(2), event, true, 100.0, Some(100.0)).as_bytes(), + ); + assert!(!state.poll_progress().unwrap()); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&miruro_video(2).id)); + for (eligible, position, duration) in [ + (true, 0.0, Some(100.0)), + (true, 94.9, Some(100.0)), + (false, 100.0, Some(100.0)), + (true, 100.0, None), + ] { + append( + &report, + miruro_record(&video, event, eligible, position, duration).as_bytes(), + ); + state.poll_progress().unwrap(); + assert!(!state.is_watched(&video.id)); + } + assert!(state.contains(CollectionKind::Watching, &video.id)); + let completion = miruro_record(&video, event, true, 95.0, Some(100.0)); + append(&report, completion.as_bytes()); + append(&report, completion.as_bytes()); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.poll_progress().unwrap()); + } +} + +#[test] +fn miruro_v2_rejects_invalid_wire_schemas_numbers_and_namespaces() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + let valid = miruro_record(&video, "progress", true, 100.0, Some(100.0)); + for line in [ + valid.replace("\"version\":2", "\"version\":1"), + valid.replace("\"version\":2", "\"version\":3"), + valid.replace("\"provider\":\"miruro\"", "\"provider\":\"youtube\""), + valid.replace("\"episode\":1", "\"episode\":0"), + valid.replace("\"episode\":1", "\"episode\":2147483648"), + valid.replace("\"position_seconds\":100.0", "\"position_seconds\":-1"), + valid.replace("\"position_seconds\":100.0", "\"position_seconds\":1e999"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":0"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":-1"), + valid.replace("\"duration_seconds\":100.0", "\"duration_seconds\":1e999"), + valid.replace("\"event\":\"progress\"", "\"event\":\"other\""), + valid.replacen('{', "{\"media_url\":null,", 1), + record(&self::video().id, "ended", true, 100.0).replace("\"version\":1", "\"version\":2"), + ] { + append(&report, line.as_bytes()); + assert!(state.poll_progress().is_err(), "{line}"); + assert!(state.videos(CollectionKind::Watching).is_empty()); + assert!(!state.is_watched(&video.id)); + assert!(!state.poll_progress().unwrap()); + } +} + +#[test] +fn miruro_partial_reports_and_bad_siblings_recover_after_restart() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (source, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + let completion = miruro_record(&video, "closed", true, 95.0, Some(100.0)); + append(&report, &completion.as_bytes()[..completion.len() - 1]); + assert!(!state.poll_progress().unwrap()); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(!state.poll_progress().unwrap()); + let other = miruro_video(2); + let (_, bad) = state + .miruro_session(&other, &miruro_source(&other)) + .unwrap(); + append(&bad, b"not json\n"); + append(&report, b"\n"); + assert!(state.poll_progress().is_err()); + assert!(state.is_watched(&video.id)); + assert!(!state.is_watched(&other.id)); + assert!(state.poll_progress().unwrap()); + assert!(!state.poll_progress().unwrap()); + drop(state); + assert!(source.exists()); + assert!(report.exists()); + let state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); +} + +#[test] +fn miruro_state_rejects_catalog_rows_bad_titles_and_youtube_metadata() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + for field in [ + "series", + "noncanonical", + "empty-title", + "control-title", + "channel", + "channel-id", + "uploader", + ] { + let mut video = miruro_video(1); + match field { + "series" => video.id = "miruro-series:opaque_Show-1".into(), + "noncanonical" => video.id = "miruro:opaque_Show-1:01".into(), + "empty-title" => video.title = " ".into(), + "control-title" => video.title.push('\n'), + "channel" => video.channel = Some("YouTube".into()), + "channel-id" => video.channel_id = self::video().channel_id, + "uploader" => video.uploader_id = Some("@jawed".into()), + _ => unreachable!(), + } + for kind in [ + CollectionKind::WatchLater, + CollectionKind::Saved, + CollectionKind::Watching, + ] { + assert!(state.save(kind, &video).is_err(), "{field}"); + } + assert!(state.progress_file(&video).is_err(), "{field}"); + } + for id in [ + "miruro-series:opaque_Show-1", + "miruro:opaque_Show-1:01", + "other:opaque_Show-1:1", + ] { + let mut watched = DocumentMut::new(); + watched["version"] = value(1); + let mut ids = Array::new(); + ids.push(id); + watched["watched"] = value(ids); + assert!(load_watched(&watched).is_err()); + } +} + +#[test] +fn miruro_source_sessions_validate_binding_size_and_maximum_identity_marker() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let mut video = miruro_video(i32::MAX as u32); + video.id = Identity::new("a".repeat(512), i32::MAX as u32) + .unwrap() + .key(); + let mut source = miruro_source(&video); + source.identity.episode -= 1; + assert!(state.miruro_session(&video, &source).is_err()); + assert!(state.streams.is_empty()); + source.identity = Identity::from_key(&video.id).unwrap(); + source.title = Some("x".repeat(MAX_SOURCE_BYTES)); + assert!(state.miruro_session(&video, &source).is_err()); + assert!(state.streams.is_empty()); + source.title = None; + let (_, report) = state.miruro_session(&video, &source).unwrap(); + append( + &report, + miruro_record(&video, "ended", true, 95.0, Some(100.0)).as_bytes(), + ); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); + drop(state); + let state = State::open(&directory.config()).unwrap(); + assert!(state.is_watched(&video.id)); +} + +#[test] +fn miruro_recovery_rejects_missing_snapshots_and_overlong_identity_markers() { + for missing_snapshot in [true, false] { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = miruro_video(1); + let (_, report) = state + .miruro_session(&video, &miruro_source(&video)) + .unwrap(); + if missing_snapshot { + fs::remove_file(report.with_file_name("video.toml")).unwrap(); + } else { + append( + &report.with_file_name("video-id"), + &vec![b'x'; MAX_IDENTITY_BYTES as usize], + ); + } + append( + &report, + miruro_record(&video, "ended", true, 100.0, Some(100.0)).as_bytes(), + ); + drop(state); + let mut state = State::open(&directory.config()).unwrap(); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } +} + +#[test] +fn youtube_v1_reports_keep_canonical_urls_and_unknown_field_tolerance() { + let directory = TestDirectory::new(); + let mut state = State::open(&directory.config()).unwrap(); + let video = video(); + let report = state.progress_file(&video).unwrap(); + register_identity(&report, &video.id).unwrap(); + let valid = record(&video.id, "ended", true, 95.0); + for url in [ + format!("https://youtu.be/{}", video.id), + format!("https://www.youtube.com/watch?v={}&t=1", video.id), + format!("http://www.youtube.com/watch?v={}", video.id), + ] { + append( + &report, + valid + .replace( + &format!("https://www.youtube.com/watch?v={}", video.id), + &url, + ) + .as_bytes(), + ); + assert!(state.poll_progress().is_err()); + assert!(!state.is_watched(&video.id)); + assert!(state.videos(CollectionKind::Watching).is_empty()); + } + append( + &report, + valid + .replacen('{', "{\"legacy_extension\":true,", 1) + .as_bytes(), + ); + assert!(state.poll_progress().unwrap()); + assert!(state.is_watched(&video.id)); +} diff --git a/tests/unit/youtube.rs b/tests/unit/youtube.rs new file mode 100644 index 0000000..45084b6 --- /dev/null +++ b/tests/unit/youtube.rs @@ -0,0 +1,1119 @@ +use super::*; + +const ONE_CHANNEL: [FeedRequest; 1] = [FeedRequest { + channel: 0, + start: 1, + count: PAGE_SIZE, +}]; + +fn full_progress(requests: &[FeedRequest]) -> Vec { + requests + .iter() + .map(|&request| FeedProgress { + request, + count: request.count, + }) + .collect() +} + +#[test] +fn feed_cursor_shares_60_rows_across_17_channels_with_continuous_uneven_offsets() { + let mut cursor = FeedCursor::default(); + assert!(!cursor.has_more()); + let mut offsets = [1; 17]; + for _ in 0..3 { + let requests = cursor.plan(17).unwrap(); + let planned = cursor.clone(); + assert_eq!( + cursor.plan(17).unwrap(), + requests, + "retrying must preserve the plan" + ); + assert_eq!( + cursor, planned, + "planning must not advance offsets or rotation" + ); + assert_eq!(requests.len(), 17); + assert_eq!( + requests.iter().map(|request| request.count).sum::(), + PAGE_SIZE + ); + assert_eq!( + requests.iter().filter(|request| request.count == 4).count(), + 9 + ); + assert_eq!( + requests.iter().filter(|request| request.count == 3).count(), + 8 + ); + assert!(requests.windows(2).all(|pair| { + (pair[0].start, pair[0].count, pair[0].channel) + < (pair[1].start, pair[1].count, pair[1].channel) + })); + for request in &requests { + assert_eq!(request.start, offsets[request.channel]); + offsets[request.channel] += request.count; + } + cursor.accept(&full_progress(&requests)).unwrap(); + } + // Rotating the nine remainder rows avoids permanently favoring the first channels. + assert!(offsets.iter().max().unwrap() - offsets.iter().min().unwrap() <= 1); + let saved = cursor.clone(); + assert!(cursor.plan(16).is_err()); + assert_eq!(cursor, saved); +} + +#[test] +fn exhausted_channel_quota_moves_to_survivors_without_restarting_offsets() { + let mut cursor = FeedCursor::default(); + let first = cursor.plan(3).unwrap(); + let mut progress = full_progress(&first); + progress + .iter_mut() + .find(|entry| entry.request.channel == 1) + .unwrap() + .count = 0; + cursor.accept(&progress).unwrap(); + let second = cursor.plan(3).unwrap(); + assert_eq!( + second, + [ + FeedRequest { + channel: 0, + start: 21, + count: 30 + }, + FeedRequest { + channel: 2, + start: 21, + count: 30 + }, + ] + ); + let mut progress = full_progress(&second); + progress[0].count = 29; + cursor.accept(&progress).unwrap(); + let last = cursor.plan(3).unwrap(); + assert_eq!( + last, + [FeedRequest { + channel: 2, + start: 51, + count: PAGE_SIZE + }] + ); + cursor + .accept(&[FeedProgress { + request: last[0], + count: 0, + }]) + .unwrap(); + assert!(!cursor.has_more()); + assert!(cursor.plan(3).unwrap().is_empty()); + let mut empty = FeedCursor::default(); + assert!(empty.plan(0).unwrap().is_empty()); + assert!(!empty.has_more()); +} + +#[test] +fn more_than_60_channels_rotate_fairly_and_unvisited_channels_do_not_exhaust() { + let mut cursor = FeedCursor::default(); + let mut seen = [false; 137]; + let mut offsets = vec![1; 137]; + for _ in 0..3 { + let requests = cursor.plan(137).unwrap(); + assert_eq!(requests.len(), PAGE_SIZE); + for request in &requests { + assert_eq!(request.count, 1); + assert_eq!(request.start, offsets[request.channel]); + seen[request.channel] = true; + offsets[request.channel] += 1; + } + cursor.accept(&full_progress(&requests)).unwrap(); + } + assert!(seen.iter().all(|seen| *seen)); + let mut cursor = FeedCursor::default(); + for expected_remaining in [77, 17, 0] { + let requests = cursor.plan(137).unwrap(); + assert_eq!( + requests.iter().map(|request| request.count).sum::(), + PAGE_SIZE + ); + let progress: Vec<_> = requests + .iter() + .map(|&request| FeedProgress { request, count: 0 }) + .collect(); + cursor.accept(&progress).unwrap(); + assert_eq!( + cursor + .positions + .as_ref() + .unwrap() + .iter() + .filter(|offset| offset.is_some()) + .count(), + expected_remaining, + ); + assert_eq!(cursor.has_more(), expected_remaining != 0); + } + assert!(cursor.plan(137).unwrap().is_empty()); +} + +#[test] +fn cursor_rejects_invalid_or_stale_progress_and_overflow_atomically() { + let mut cursor = FeedCursor::default(); + assert!(cursor.accept(&[]).is_err()); + let requests = cursor.plan(3).unwrap(); + let valid = full_progress(&requests); + let saved = cursor.clone(); + let mut bad = valid.clone(); + bad[2].count += 1; + assert!(cursor.accept(&bad).is_err()); + assert_eq!(cursor, saved); + bad = valid.clone(); + bad[2].request.channel = 99; + assert!(cursor.accept(&bad).is_err()); + assert_eq!(cursor, saved); + bad = valid.clone(); + bad[2].request = bad[0].request; + assert!(cursor.accept(&bad).is_err()); + assert_eq!(cursor, saved); + bad = valid.clone(); + bad[2].request.start += 1; + assert!(cursor.accept(&bad).is_err()); + assert_eq!(cursor, saved); + bad = valid.clone(); + bad.reverse(); + assert!(cursor.accept(&bad).is_err()); + assert_eq!(cursor, saved); + assert!(cursor.accept(&valid[..2]).is_err()); + assert_eq!(cursor, saved); + cursor.accept(&valid).unwrap(); + let accepted = cursor.clone(); + assert!(cursor.accept(&valid).is_err()); + assert_eq!(cursor, accepted); + + let mut overflow = FeedCursor { + positions: Some(vec![Some(1), Some(usize::MAX - 19), Some(1)]), + rotation: 0, + }; + let requests = overflow.plan(3).unwrap(); + let saved = overflow.clone(); + assert!(overflow.accept(&full_progress(&requests)).is_err()); + assert_eq!(overflow, saved); +} + +#[cfg(unix)] +mod subprocesses { + use super::*; + + fn controlled(script: &str) -> Command { + let mut command = isolated_command(OsStr::new("/bin/sh")); + command.args(["-c", &format!("kill -STOP $$; {script}")]); + command + } + + fn wait_stopped(extraction: &Extraction) { + wait_stopped_pid(extraction.child.as_ref().unwrap().id()); + } + + fn wait_stopped_pid(pid: u32) { + let pid = pid as i32; + let mut status = 0; + loop { + // SAFETY: wait only for this test's owned child, without reaping its exit. + let result = unsafe { libc::waitpid(pid, &mut status, libc::WUNTRACED) }; + if result == -1 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + assert_eq!(result, pid); + assert!(libc::WIFSTOPPED(status)); + break; + } + } + + fn release(extraction: &mut Extraction) { + let child = extraction.child.as_mut().unwrap(); + // SAFETY: this child is stopped at the deterministic test barrier. + assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGCONT) }, 0); + child.wait().unwrap(); + // Synchronize pipe draining too, then leave the real result for Job::poll. + // Blocking is confined to tests; production polling never waits for output. + let result = extraction.output.recv().unwrap(); + let (sender, receiver) = mpsc::channel(); + sender.send(result).unwrap(); + extraction.output = receiver; + } + + fn take_output(extraction: &mut Extraction) -> Receiver> { + let (_, receiver) = mpsc::channel(); + std::mem::replace(&mut extraction.output, receiver) + } + + fn assert_reaped(pid: u32) { + let mut status = 0; + // SAFETY: query only this test's child; ECHILD proves cancellation reaped it. + assert_eq!( + unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }, + -1 + ); + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } + + #[test] + fn a_later_spawn_failure_cancels_and_reaps_already_started_batches() { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let directory = std::env::temp_dir().join(format!( + "tubular-extractor-spawn-{}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir(&directory).unwrap(); + let ready = directory.join("ready"); + let mut first = isolated_command(OsStr::new("/bin/sh")); + first + .args(["-c", "printf '%s' $$ > \"$1\"; kill -STOP $$", "test"]) + .arg(&ready); + let mut started_pid = None; + let commands = std::iter::once(first).chain(std::iter::once_with(|| { + // The file only communicates identity; SIGSTOP is the completion barrier. + let pid = loop { + if let Ok(text) = std::fs::read_to_string(&ready) + && let Ok(pid) = text.parse::() + { + break pid; + } + thread::yield_now(); + }; + wait_stopped_pid(pid); + started_pid = Some(pid); + isolated_command(directory.join("missing-extractor").as_os_str()) + })); + let error = Job::spawn_all(commands).err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + assert_reaped(started_pid.unwrap()); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn queued_range_groups_keep_four_workers_and_preserve_parser_request_order() { + let requests: Vec<_> = (0..6) + .map(|channel| FeedRequest { + channel, + start: channel + 1, + count: channel + 1, + }) + .collect(); + let commands = requests.iter().map(|request| { + let output = + serde_json::json!({"entries": vec![serde_json::Value::Null; request.count]}); + controlled(&format!("printf '%s' '{output}'")) + }); + let mut job = Job::spawn_all(commands).unwrap(); + assert_eq!(job.children.len(), MAX_EXTRACTORS); + assert_eq!(job.queued.len(), 2); + for child in &job.children { + wait_stopped(child); + } + release(&mut job.children[3]); + assert!(job.poll().is_none()); + assert_eq!(job.queued.len(), 1); + assert_eq!( + job.children + .iter() + .filter(|child| child.completed.is_none()) + .count(), + MAX_EXTRACTORS + ); + wait_stopped(&job.children[4]); + release(&mut job.children[4]); + release(&mut job.children[1]); + assert!(job.poll().is_none()); + assert!(job.queued.is_empty()); + assert!( + job.children + .iter() + .filter(|child| child.completed.is_none()) + .count() + <= MAX_EXTRACTORS + ); + wait_stopped(&job.children[5]); + release(&mut job.children[5]); + assert!(job.poll().is_none()); + release(&mut job.children[2]); + assert!(job.poll().is_none()); + release(&mut job.children[0]); + let output = job.poll().unwrap().unwrap(); + let page = feed_results(&output, &requests).unwrap(); + assert_eq!(page.feed_progress, full_progress(&requests)); + assert!(page.videos.is_empty()); + assert!(job.poll().is_none()); + } + + #[test] + fn queued_spawn_failure_discards_outputs_and_cancels_running_and_queued_groups() { + let mut job = Job::spawn_all([ + controlled("printf '%s' 'completed output'"), + controlled("printf '%s' 'pending one'"), + controlled("printf '%s' 'pending two'"), + controlled("printf '%s' 'pending three'"), + isolated_command(OsStr::new("/dev/null/tubular-missing-extractor")), + controlled("printf '%s' 'must never start'"), + ]) + .unwrap(); + for child in &job.children { + wait_stopped(child); + } + let pids: Vec<_> = job.children[1..] + .iter() + .map(|child| child.child.as_ref().unwrap().id()) + .collect(); + let outputs: Vec<_> = job.children[1..].iter_mut().map(take_output).collect(); + release(&mut job.children[0]); + assert!( + job.poll() + .unwrap() + .unwrap_err() + .starts_with("Cannot start yt-dlp:") + ); + assert!(job.children.is_empty()); + assert!(job.queued.is_empty()); + for output in outputs { + assert!(output.recv().unwrap().is_ok()); + } + for pid in pids { + assert_reaped(pid); + } + assert!(job.poll().is_none()); + } + + #[test] + fn aggregate_waits_for_all_batches_and_merges_in_request_not_completion_order() { + let mut job = Job::spawn_all([ + controlled("printf '%s' '{\"batch\":1}'"), + controlled("printf '%s' '{\"batch\":2}'"), + controlled("printf '%s' '{\"batch\":3}'"), + ]) + .unwrap(); + for child in &job.children { + wait_stopped(child); + } + assert!(job.poll().is_none()); + release(&mut job.children[2]); + assert!( + job.poll().is_none(), + "the last batch cannot complete a page" + ); + release(&mut job.children[1]); + assert!(job.poll().is_none(), "the first batch is still outstanding"); + release(&mut job.children[0]); + let output = job.poll().unwrap().unwrap(); + let batches: Vec<_> = serde_json::Deserializer::from_str(&output) + .into_iter::() + .map(Result::unwrap) + .collect(); + assert_eq!( + batches, + [ + serde_json::json!({"batch":1}), + serde_json::json!({"batch":2}), + serde_json::json!({"batch":3}), + ] + ); + assert!(job.poll().is_none()); + } + + #[test] + fn a_failed_group_discards_completed_output_and_cancels_running_and_queued_siblings() { + let mut job = Job::spawn_all([ + controlled("printf '%s' '{\"complete\":true}'"), + controlled("printf '%s' '{\"partial\":true}'; printf '%s' 'batch failed' >&2; exit 7"), + controlled("/bin/sh -c 'kill -STOP $$' & wait"), + controlled("printf '%s' 'pending sibling'"), + controlled("printf '%s' 'queued sibling'"), + controlled("printf '%s' 'must never start'"), + ]) + .unwrap(); + for child in &job.children { + wait_stopped(child); + } + release(&mut job.children[0]); + assert!(job.poll().is_none()); + wait_stopped(&job.children[4]); + assert_eq!(job.queued.len(), 1); + let pids: Vec<_> = job.children[2..] + .iter() + .map(|child| child.child.as_ref().unwrap().id()) + .collect(); + let outputs: Vec<_> = job.children[2..].iter_mut().map(take_output).collect(); + release(&mut job.children[1]); + assert_eq!(job.poll().unwrap().unwrap_err(), "batch failed"); + assert!(job.children.is_empty()); + assert!(job.queued.is_empty()); + for output in outputs { + assert!(output.recv().unwrap().is_ok()); + } + for pid in pids { + assert_reaped(pid); + } + assert!(job.poll().is_none()); + } + + #[test] + fn unreadable_batch_output_fails_the_whole_page_and_cancels_siblings() { + let mut job = Job::spawn_all([ + controlled("printf '\\377'"), + controlled("printf '%s' 'must not finish'"), + ]) + .unwrap(); + for child in &job.children { + wait_stopped(child); + } + let pending_pid = job.children[1].child.as_ref().unwrap().id(); + let pending_output = take_output(&mut job.children[1]); + release(&mut job.children[0]); + assert!( + job.poll() + .unwrap() + .unwrap_err() + .starts_with("Cannot read yt-dlp output:") + ); + assert!(pending_output.recv().unwrap().is_ok()); + assert_reaped(pending_pid); + } + + #[test] + fn dropping_a_page_cancels_and_reaps_running_groups_and_drops_queued_commands() { + let mut job = Job::spawn_all([ + controlled("/bin/sh -c 'kill -STOP $$' & wait"), + controlled("printf '%s' 'must not finish'"), + controlled("printf '%s' 'pending two'"), + controlled("printf '%s' 'pending three'"), + controlled("printf '%s' 'queued one must never start'"), + controlled("printf '%s' 'queued two must never start'"), + ]) + .unwrap(); + assert_eq!(job.children.len(), MAX_EXTRACTORS); + assert_eq!(job.queued.len(), 2); + for child in &job.children { + wait_stopped(child); + } + let pids: Vec<_> = job + .children + .iter() + .map(|child| child.child.as_ref().unwrap().id()) + .collect(); + let outputs: Vec<_> = job.children.iter_mut().map(take_output).collect(); + drop(job); + for output in outputs { + assert!(output.recv().unwrap().is_ok()); + } + for pid in pids { + assert_reaped(pid); + } + } + + #[test] + fn dropping_after_the_leader_exits_still_cancels_inherited_pipe_owners() { + let mut job = Job::spawn_all([controlled("/bin/sh -c 'kill -STOP $$' & exit 0")]).unwrap(); + wait_stopped(&job.children[0]); + let child = job.children[0].child.as_mut().unwrap(); + let pid = child.id(); + // SAFETY: resume this test's stopped leader, which exits without its descendant. + assert_eq!(unsafe { libc::kill(pid as i32, libc::SIGCONT) }, 0); + assert!(child.wait().unwrap().success()); + let output = take_output(&mut job.children[0]); + drop(job); + // EOF proves the descendant that inherited stdout/stderr was also canceled. + assert!(output.recv().unwrap().is_ok()); + assert_reaped(pid); + } +} + +#[test] +fn player_launch_rejects_unsafe_video_ids_before_spawning() { + for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { + assert!(matches!( + PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } +} + +#[test] +fn browser_open_rejects_unsafe_video_ids_before_spawning() { + for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] { + assert!(matches!( + open_in_browser(id), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } +} + +#[test] +fn publication_dates_prefer_exact_metadata_and_handle_utc_boundaries_and_invalid_dates() { + for feed in [false, true] { + for (upload_date, timestamp, expected) in [ + (Some("20240229"), Some(0), Some("2024-02-29")), + (Some("20000229"), None, Some("2000-02-29")), + (Some("19000229"), None, None), + (Some("20230229"), Some(0), Some("~1970-01-01")), + (Some("20241301"), None, None), + (Some("日本語"), None, None), + (None, Some(-1), Some("~1969-12-31")), + (None, Some(1_709_251_199), Some("~2024-02-29")), + (None, Some(1_709_251_200), Some("~2024-03-01")), + (None, Some(i64::MAX), None), + (None, None, None), + ] { + let output = serde_json::json!({"entries":[{ + "id": "jNQXAC9IVRw", "title": "Video", + "upload_date": upload_date, "timestamp": timestamp, + }]}) + .to_string(); + let page = if feed { + feed_results(&output, &ONE_CHANNEL) + } else { + search_results(&output) + } + .unwrap(); + assert_eq!(page.videos[0].publication_date.as_deref(), expected); + } + } +} + +#[test] +fn thumbnails_bound_binary_input_and_reject_unsafe_ids() { + let bytes = [0xff, 0xd8, 0x00, 0xfe]; + assert_eq!(read_thumbnail(bytes.as_slice()).unwrap(), bytes); + assert_eq!( + read_thumbnail(io::repeat(0).take(MAX_THUMBNAIL_BYTES as u64)) + .unwrap() + .len(), + MAX_THUMBNAIL_BYTES + ); + assert!(read_thumbnail(io::repeat(0)).is_err()); + for id in ["../secrets", "abcdefghij/", "abcdefghijé", "--bad"] { + assert!(matches!( + ThumbnailJob::start(id), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + for url in [ + "http://example.test/cover.jpg", + "file:///tmp/cover.jpg", + "ftp://example.test/cover.jpg", + "//example.test/cover.jpg", + "https://", + "https://user:secret@example.test/cover.jpg", + "https://example.test/\ncover.jpg", + ] { + assert!(matches!( + ThumbnailJob::from_url(url), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } +} + +#[test] +fn bad_video_entries_do_not_discard_valid_results() { + for feed in [false, true] { + for bad in [ + r#"{"id":"UC4QobU6STFB0P71PMvOGN5A","title":"Channel result"}"#, + r#"{"id":"--bad","title":"Unsafe ID"}"#, + r#"{"id":"jNQXAC9IVRw","title":"Bad channel","channel_id":"malformed"}"#, + r#"{"id":"jNQXAC9IVRw","title":"Bad handle","uploader_id":"@jawed\u001b"}"#, + r#"{"id":42,"title":"Wrong ID type"}"#, + r#"{"id":"jNQXAC9IVRw","title":null}"#, + r#"{"id":"jNQXAC9IVRw","title":"Bad date type","timestamp":"yesterday"}"#, + r#"{"id":"jNQXAC9IVRw","title":"Bad channel type","channel":[]}"#, + r#"{"id":"jNQXAC9IVRw"}"#, + "null", + "42", + "[]", + "{}", + ] { + let output = format!( + r#"{{"entries":[{{"id":"jNQXAC9IVRw","title":"Before"}},{bad},{{"id":"rQ_J9WH6CGk","title":"After"}}]}}"#, + ); + let page = if feed { + feed_results(&output, &ONE_CHANNEL) + } else { + search_results(&output) + } + .unwrap_or_else(|error| panic!("bad entry {bad} discarded the entire page: {error}")); + assert_eq!( + page.videos + .iter() + .map(|video| video.title.as_str()) + .collect::>(), + ["Before", "After"] + ); + } + } +} + +#[test] +fn members_only_rows_are_hidden_without_exhausting_search_or_feed_pages() { + let members = serde_json::json!({ + "id": "jNQXAC9IVRw", "title": "Members only", "availability": "subscriber_only" + }); + let mut entries = vec![members; PAGE_SIZE]; + let output = serde_json::json!({"entries": entries}).to_string(); + let search = search_results(&output).unwrap(); + assert!(search.videos.is_empty()); + assert!(search.has_more); + let mut cursor = FeedCursor::default(); + let requests = cursor.plan(1).unwrap(); + let feed = feed_results(&output, &requests).unwrap(); + assert!(feed.videos.is_empty()); + assert!(feed.has_more); + cursor.accept(&feed.feed_progress).unwrap(); + assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1); + + for (index, availability) in [ + Some("public"), + Some("unlisted"), + None, + Some("new_availability"), + ] + .into_iter() + .enumerate() + { + entries[index] = serde_json::json!({ + "id": format!("{index:011}"), + "title": "Join my channel membership", + "availability": availability, + }); + } + entries[4] = serde_json::json!({"id": "rQ_J9WH6CGk", "title": "No availability"}); + let output = serde_json::json!({"entries": entries}).to_string(); + for page in [ + search_results(&output).unwrap(), + feed_results(&output, &cursor.plan(1).unwrap()).unwrap(), + ] { + assert_eq!( + page.videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + [ + "00000000000", + "00000000001", + "00000000002", + "00000000003", + "rQ_J9WH6CGk" + ] + ); + assert!(page.has_more); + } +} + +#[test] +fn discarded_raw_entries_advance_the_cursor_only_after_acceptance() { + let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500}); + let mut entries = vec![bad; PAGE_SIZE]; + let output = serde_json::json!({"entries": entries}).to_string(); + let search = search_results(&output).unwrap(); + assert!(search.videos.is_empty()); + assert!(search.has_more); + assert!(search.feed_progress.is_empty()); + let mut cursor = FeedCursor::default(); + let requests = cursor.plan(1).unwrap(); + let saved = cursor.clone(); + let page = feed_results(&output, &requests).unwrap(); + assert!(page.videos.is_empty()); + assert!(page.has_more); + assert_eq!(page.feed_progress, full_progress(&requests)); + assert_eq!(cursor, saved, "parsing alone must not advance the cursor"); + cursor.accept(&page.feed_progress).unwrap(); + assert_eq!(cursor.plan(1).unwrap()[0].start, PAGE_SIZE + 1); + + entries[0] = serde_json::json!({"id":"jNQXAC9IVRw","title":"Valid","timestamp":100}); + let page = feed_results( + &serde_json::json!({"entries": entries}).to_string(), + &cursor.plan(1).unwrap(), + ) + .unwrap(); + assert!(page.has_more); + assert_eq!(page.feed_progress[0].count, PAGE_SIZE); + assert_eq!(page.videos[0].id, "jNQXAC9IVRw"); +} + +#[test] +fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() { + let videos = search_results( + r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#, + ) + .unwrap() + .videos; + assert_eq!(videos[0].title, "Rust"); + assert_eq!(videos[0].channel, None); + assert_eq!(videos[0].channel_id, None); + assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos")); + for output in [ + "not JSON", + "{}", + r#"{"entries":null}"#, + r#"{"entries":{}}"#, + r#"{"entries":[{"id":} ]}"#, + ] { + assert!(search_results(output).is_err()); + } +} + +#[test] +fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() { + let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]} +{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust","entries":[{"id":"SLV9AJxnb1U","title":"Newest","timestamp":300},{"id":"l-GaaivVJCE","title":"Newer","timestamp":200},{"id":"rQ_J9WH6CGk","title":"No metadata","channel_id":null}]}"#; + let requests = FeedCursor::default().plan(2).unwrap(); + let page = feed_results(output, &requests).unwrap(); + assert!(!page.has_more); + assert_eq!( + page.feed_progress + .iter() + .map(|entry| entry.count) + .collect::>(), + [3, 3] + ); + let videos = page.videos; + assert_eq!( + videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + ["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"] + ); + assert_eq!(videos[1].title, "Newer"); + assert_eq!(videos[1].timestamp, Some(200)); + assert_eq!(videos[2].timestamp, None); + assert_eq!(videos[2].channel.as_deref(), Some("Rust")); + assert!( + videos + .iter() + .all(|video| video.channel_id.as_deref() == Some("UCaYhcUwRBNscFNUKTjgPFiA")) + ); + assert!( + videos + .iter() + .all(|video| video.uploader_id.as_deref() == Some("@RustVideos")) + ); + let unavailable = feed_results( + r#"{"entries":[null,{"id":"jNQXAC9IVRw","title":"Unknown","channel_id":null}]}"#, + &ONE_CHANNEL, + ) + .unwrap() + .videos; + assert_eq!(unavailable[0].channel_id, None); + assert!(feed_results(r#"{"channel_id":"malformed","entries":[]}"#, &ONE_CHANNEL).is_err()); + assert!(feed_results("", &ONE_CHANNEL).is_err()); + assert!(feed_results("{}", &ONE_CHANNEL).is_err()); + assert!(feed_results("{not JSON}", &ONE_CHANNEL).is_err()); + let exhausted = feed_results(r#"{"entries":[]}"#, &ONE_CHANNEL).unwrap(); + assert!(exhausted.videos.is_empty()); + assert!(!exhausted.has_more); + assert_eq!(exhausted.feed_progress[0].count, 0); +} + +#[test] +fn channel_search_query_preserves_utf8_and_isolates_url_parameters() { + assert_eq!( + encode_query("café 🦀 &next=1#?/+%20\t\n\0 AZaz09-._~"), + "caf%C3%A9%20%F0%9F%A6%80%20%26next%3D1%23%3F%2F%2B%2520%09%0A%00%20AZaz09-._~" + ); +} + +#[test] +fn page_offsets_reject_zero_and_overflow_before_spawning() { + assert_eq!(page_end(1).unwrap(), PAGE_SIZE); + assert_eq!(page_end(PAGE_SIZE + 1).unwrap(), PAGE_SIZE * 2); + assert_eq!(page_end(usize::MAX - (PAGE_SIZE - 1)).unwrap(), usize::MAX); + let channels = vec!["https://www.youtube.com/@RustVideos".to_owned()]; + for start in [0, usize::MAX, usize::MAX - (PAGE_SIZE - 2)] { + assert!(matches!( + Job::search("rust", start), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + assert!(matches!( + Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }], ""), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + assert!(matches!( + Job::feed(&[], &ONE_CHANNEL, ""), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); +} + +#[test] +fn feeds_reject_invalid_request_budgets_and_envelope_or_row_count_mismatches() { + let channels = vec!["https://www.youtube.com/@RustVideos".to_owned(); 2]; + for requests in [ + Vec::new(), + vec![FeedRequest { + count: 0, + ..ONE_CHANNEL[0] + }], + vec![FeedRequest { + count: PAGE_SIZE + 1, + ..ONE_CHANNEL[0] + }], + vec![FeedRequest { + channel: 2, + ..ONE_CHANNEL[0] + }], + vec![ + FeedRequest { + channel: 0, + start: 1, + count: 30, + }, + FeedRequest { + channel: 0, + start: 2, + count: 30, + }, + ], + vec![ + FeedRequest { + channel: 0, + start: 1, + count: 30, + }, + FeedRequest { + channel: 1, + start: 1, + count: 30, + }, + ], + vec![ + FeedRequest { + channel: 0, + start: 1, + count: 31, + }, + FeedRequest { + channel: 1, + start: 1, + count: 30, + }, + ], + ] { + assert!(matches!( + Job::feed(&channels, &requests, ""), + Err(error) if error.kind() == io::ErrorKind::InvalidInput + )); + } + let requests = FeedCursor::default().plan(2).unwrap(); + assert!(feed_results(r#"{"entries":[]}"#, &requests).is_err()); + for output in [ + r#"{"entries":[]} {"entries":[]}"#, + r#"{"entries":[]} garbage"#, + r#"{"entries":[null]} {"entries":null}"#, + r#"{"entries":null}"#, + r#"{"entries":{}}"#, + r#"{"entries":[{"id":}]}"#, + ] { + assert!(feed_results(output, &ONE_CHANNEL).is_err()); + } + let request = [FeedRequest { + channel: 0, + start: 1, + count: 2, + }]; + assert!(feed_results(r#"{"entries":[null,null,null]}"#, &request).is_err()); + assert!(feed_results(r#"{"entries":[]}"#, &[]).is_err()); + assert!( + feed_results( + r#"{"entries":[]}"#, + &[FeedRequest { + count: 0, + ..ONE_CHANNEL[0] + }] + ) + .is_err() + ); + assert!( + feed_results( + r#"{"entries":[]}"#, + &[FeedRequest { + count: PAGE_SIZE + 1, + ..ONE_CHANNEL[0] + }] + ) + .is_err() + ); +} + +#[test] +fn search_pagination_counts_raw_rows_and_preserves_relevance_order() { + for count in [0, PAGE_SIZE - 1, PAGE_SIZE, PAGE_SIZE + 1] { + let output = serde_json::json!({ + "entries": vec![serde_json::Value::Null; count] + }) + .to_string(); + let page = search_results(&output).unwrap(); + assert_eq!(page.has_more, count >= PAGE_SIZE); + assert!(page.videos.is_empty()); + assert!(page.feed_progress.is_empty()); + } + let mut entries = vec![serde_json::Value::Null; PAGE_SIZE]; + entries[0] = serde_json::json!({ + "id": "aaaaaaaaaaa", "title": "Relevant older video", "timestamp": 100 + }); + entries[PAGE_SIZE - 1] = serde_json::json!({ + "id": "bbbbbbbbbbb", "title": "Less relevant newer video", "timestamp": 300 + }); + let page = search_results(&serde_json::json!({"entries": entries}).to_string()).unwrap(); + assert!(page.has_more); + assert!(page.feed_progress.is_empty()); + assert_eq!(page.videos.len(), 2); + assert_eq!(page.videos[0].id, "aaaaaaaaaaa"); + assert_eq!(page.videos[1].id, "bbbbbbbbbbb"); +} + +#[test] +fn feed_pagination_counts_nulls_and_duplicates_before_filtering() { + for count in [0, PAGE_SIZE - 1, PAGE_SIZE] { + let output = serde_json::json!({ + "entries": vec![serde_json::Value::Null; count] + }) + .to_string(); + let page = feed_results(&output, &ONE_CHANNEL).unwrap(); + assert_eq!(page.has_more, count == PAGE_SIZE); + assert!(page.videos.is_empty()); + assert_eq!( + page.feed_progress, + [FeedProgress { + request: ONE_CHANNEL[0], + count + }] + ); + } + let entry = serde_json::json!({ + "id": "aaaaaaaaaaa", "title": "Repeated", "timestamp": 123 + }); + let page = feed_results( + &serde_json::json!({"entries": vec![entry; PAGE_SIZE]}).to_string(), + &ONE_CHANNEL, + ) + .unwrap(); + assert!(page.has_more); + assert_eq!(page.feed_progress[0].count, PAGE_SIZE); + assert_eq!(page.videos.len(), 1); + + let mut entries = vec![serde_json::Value::Null; PAGE_SIZE]; + entries[0] = serde_json::json!({"id": "bbbbbbbbbbb", "title": "Unknown"}); + let page = feed_results( + &serde_json::json!({"entries": entries}).to_string(), + &ONE_CHANNEL, + ) + .unwrap(); + assert!(page.has_more); + assert_eq!(page.feed_progress[0].count, PAGE_SIZE); + assert_eq!(page.videos.len(), 1); + assert_eq!(page.videos[0].timestamp, None); +} + +#[test] +fn feeds_retain_short_and_undated_rows_without_a_global_frontier() { + let requests = FeedCursor::default().plan(5).unwrap(); + let mut first = vec![serde_json::Value::Null; requests[0].count]; + first[0] = serde_json::json!({"id":"aaaaaaaaaaa","title":"Older duplicate","timestamp":500}); + first[1] = serde_json::json!({"id":"bbbbbbbbbbb","title":"First dated row","timestamp":300}); + first[2] = serde_json::json!({"id":"ccccccccccc","title":"Unknown"}); + let mut second = vec![serde_json::Value::Null; requests[1].count]; + second[0] = serde_json::json!({"id":"aaaaaaaaaaa","title":"Newest duplicate","timestamp":600}); + second[1] = serde_json::json!({"id":"ddddddddddd","title":"Second dated row","timestamp":400}); + let short = serde_json::json!({"entries":[ + {"id":"eeeeeeeeeee","title":"Short newer","timestamp":999} + ]}); + let older_short = serde_json::json!({"entries":[ + {"id":"fffffffffff","title":"Short older","timestamp":50} + ]}); + let output = format!( + "{}\n{}\n{}\n{}\n{}", + serde_json::json!({"entries": first}), + serde_json::json!({"entries": second}), + short, + older_short, + serde_json::json!({"entries":[]}) + ); + let page = feed_results(&output, &requests).unwrap(); + assert!(page.has_more); + assert_eq!( + page.feed_progress + .iter() + .map(|entry| entry.count) + .collect::>(), + [12, 12, 1, 1, 0] + ); + assert_eq!( + page.videos + .iter() + .map(|video| video.id.as_str()) + .collect::>(), + [ + "eeeeeeeeeee", + "aaaaaaaaaaa", + "ddddddddddd", + "bbbbbbbbbbb", + "fffffffffff", + "ccccccccccc" + ] + ); + assert_eq!(page.videos[1].title, "Newest duplicate"); + assert_eq!(page.videos.last().unwrap().timestamp, None); + + let requests = FeedCursor::default().plan(3).unwrap(); + let exhausted = feed_results( + &format!( + "{}\n{}\n{}", + short, + older_short, + serde_json::json!({"entries":[]}) + ), + &requests, + ) + .unwrap(); + assert!(!exhausted.has_more); + assert_eq!(exhausted.videos.len(), 2); +} + +#[test] +fn channel_handles_require_the_requested_identity_and_a_valid_handle() { + let id = "UChAQQ9at2MDlXjvQX3vL3Yw"; + for handle in ["@ch.1676", "@敷嶋てとら", "@hontame_"] { + let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); + assert_eq!(channel_handle(&metadata, id).unwrap(), handle); + assert!(channel_handle(&metadata, "UCaYhcUwRBNscFNUKTjgPFiA").is_err()); + } + for handle in [ + None, + Some("ch.1676"), + Some("@bad/handle"), + Some("@bad\u{1b}"), + ] { + let metadata = serde_json::json!({"channel_id": id, "uploader_id": handle}).to_string(); + assert!(channel_handle(&metadata, id).is_err()); + } + assert!(channel_handle("not JSON", id).is_err()); +} + +#[test] +fn full_descriptions_preserve_paragraphs_and_reject_mismatched_videos() { + let metadata = r#"{"id":"jNQXAC9IVRw","description":"First paragraph.\n\n日本語\tsecond paragraph.\u001b\nLast line."}"#; + assert_eq!( + full_description(metadata, "jNQXAC9IVRw").unwrap(), + "First paragraph.\n\n日本語\tsecond paragraph.\nLast line." + ); + assert!(full_description(metadata, "rQ_J9WH6CGk").is_err()); + assert!(full_description("not JSON", "jNQXAC9IVRw").is_err()); + assert_eq!( + full_description(r#"{"id":"jNQXAC9IVRw","description":null}"#, "jNQXAC9IVRw").unwrap(), + "" + ); +}