Files
tubular/tests/unit/app.rs
T

6085 lines
238 KiB
Rust

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 playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
let mut app = app(&["tubular"]);
app.view_index = 1;
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"New","timestamp":200}]}"#,
true,
);
let view = &mut app.views[1];
view.selection.select(Some(1));
view.toggle_checked();
view.selection.select(Some(0));
view.toggle_checked();
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Newest","timestamp":300}]}"#,
false,
);
let view = &mut app.views[1];
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
view.reset();
assert!(view.checked.is_empty());
// Old rows remain during refresh, but checks made there cannot leak into replacement data.
view.toggle_checked();
assert!(!view.checked.is_empty());
view.accept_page(
youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#)
.unwrap(),
false,
)
.unwrap();
assert!(view.checked.is_empty());
assert_eq!(view.playback_videos()[0].title, "Replacement");
}
#[test]
fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() {
let mut app = app(&["tubular"]);
let 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].videos = videos.clone();
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.views[1].loaded = true;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.switch_view(1);
assert!(app.views[1].checked.is_empty());
app.switch_view(0);
assert!(app.views[0].checked.contains(&videos[0].id));
app.mode = Mode::Insert;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.views[0].checked.len(), 1);
app.mode = Mode::Normal;
for preview in [
None,
Some(PreviewMode::Info),
Some(PreviewMode::Description),
] {
app.preview = preview;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(app.views[0].checked.is_empty());
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(app.views[0].checked.contains(&videos[0].id));
}
app.preview = None;
for video in &videos {
app.video_state
.save(CollectionKind::WatchLater, video)
.unwrap();
}
app.browse_collection(CollectionKind::WatchLater);
let index = app.view_index;
app.views[index].selection.select(Some(0));
app.views[index].toggle_checked();
app.views[index].selection.select(Some(1));
app.views[index].toggle_checked();
let removed = app.views[index].videos[1].id.clone();
app.remove_collection();
assert!(!app.views[index].checked.contains(&removed));
assert_eq!(app.views[index].checked.len(), 1);
assert_eq!(app.views[index].playback_videos().len(), 1);
app.views[index].watch_filter = WatchFilter::Watched;
app.rebuild_collections();
assert!(app.views[index].videos.is_empty());
assert!(app.views[index].checked.is_empty());
app.views[index].watch_filter = WatchFilter::All;
app.rebuild_collections();
app.views[index].toggle_checked();
// Refresh is a replacement even for locally rebuilt collection views.
app.load_view();
assert!(app.views[index].checked.is_empty());
assert_eq!(app.views[0].checked.len(), 1);
// An empty category exercises query replacement entirely offline.
app.config.categories.insert("Empty".into(), Vec::new());
let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into()));
view.videos = videos;
view.selection.select(Some(0));
view.toggle_checked();
view.query = "replacement query".into();
app.views.push(view);
app.view_index = app.views.len() - 1;
app.search();
assert!(app.views[app.view_index].checked.is_empty());
assert!(app.job.is_none());
}
#[test]
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
let mut app = app(&["tubular"]);
let document =
"[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n[keys.video]\nenqueue = 'f22'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
app.key_hints = KeyHints::new(&app.config.keys);
for context in [
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
] {
for key in ["F20", "F21", "F22"] {
assert!(app.key_hints.footer(context).contains(key));
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
assert!(app.config.keys.help_text().to_string().contains(key));
}
}
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#,
).unwrap().videos;
for (index, video) in app.views[0].videos.iter_mut().enumerate() {
video.id = format!("offline:{index}");
}
app.views[0].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE));
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("1 checked"));
app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE));
assert!(app.views[0].checked.is_empty());
}
#[test]
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
for modifiers in [
KeyModifiers::NONE,
KeyModifiers::SUPER,
KeyModifiers::CONTROL,
] {
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;
app.views[0].selection.select(Some(1));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.views[0].toggle_checked();
app.views[0].selection.select(Some(0));
app.options.player = "/nonexistent/tubular-test-player".into();
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_none());
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.options.player = "/usr/bin/true".into();
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_some());
assert!(app.views[0].checked.is_empty());
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
app.player_launch = None;
app.key(KeyEvent::new(KeyCode::Enter, modifiers));
assert!(app.player_launch.is_some());
}
}
#[test]
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
let mut app = app(&["tubular"]);
let template =
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Listing title"}]}"#)
.unwrap()
.videos
.remove(0);
app.views[0].videos = (0..12)
.map(|index| {
let mut video = template.clone();
// Non-YouTube IDs keep this rendering check entirely offline.
video.id = format!("offline:{index}");
video.title = format!("Row {index:02}");
video
})
.collect();
app.views[0].selection.select(Some(9));
let now = Instant::now();
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw_at(frame, Duration::ZERO, now))
.unwrap();
assert_eq!(app.views[0].selection.offset(), 3);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:9"
);
assert!(!app.titles.active());
terminal.backend_mut().resize(80, 17);
terminal
.draw(|frame| {
app.draw_at(
frame,
Duration::from_millis(20),
now + Duration::from_millis(20),
)
})
.unwrap();
assert_eq!(app.views[0].selection.offset(), 7);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:9"
);
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("Row 09"));
app.views[0].selection.select(Some(0));
terminal
.draw(|frame| {
app.draw_at(
frame,
Duration::from_millis(40),
now + Duration::from_millis(40),
)
})
.unwrap();
assert_eq!(app.views[0].selection.offset(), 0);
assert_eq!(
app.views[0].videos[app.views[0].selection.selected().unwrap()].id,
"offline:0"
);
}
#[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::<toml_edit::DocumentMut>().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::<toml_edit::DocumentMut>()
.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<Page, String> {
let channels = serde_json::Deserializer::from_str(output)
.into_iter::<serde_json::Value>()
.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::<serde_json::Value>()
.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::<Vec<_>>()
.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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<Vec<_>>(),
["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::<toml_edit::DocumentMut>()
.unwrap();
assert_eq!(
disk["categories"]["Saved union"]
.as_array()
.unwrap()
.iter()
.map(|value| value.as_str().unwrap())
.collect::<Vec<_>>(),
["@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::<toml_edit::DocumentMut>().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 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::<toml_edit::DocumentMut>()
.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::<toml_edit::DocumentMut>().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::<Vec<_>>(),
["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::<toml_edit::DocumentMut>().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",
"duration": 3723.5
})).collect::<Vec<_>>()
})
.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 results border and two-line entries.
for (index, (label, _, _, tagged)) in cases.iter().enumerate() {
let y = 6 + 2 * 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}"
);
let title: String = (0..120).map(|x| buffer[(x, y - 1)].symbol()).collect();
assert!(title.contains("Video title"));
let date_x = row.find("2026-10-11").expect("date follows channel") as u16;
assert!(row.contains(&format!("{label} · 2026-10-11")));
assert!(
row.contains(" · 1:02:03"),
"duration is visible in metadata"
);
assert_eq!(buffer[(3, y - 1)].fg, Color::Reset, "title stays uncolored");
assert_eq!(buffer[(date_x, y)].fg, Color::Reset, "date stays uncolored");
assert!(
buffer[(x, y)]
.modifier
.contains(ratatui::style::Modifier::DIM)
);
}
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::<toml_edit::DocumentMut>()
.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::<toml_edit::DocumentMut>()
.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::<toml_edit::DocumentMut>()
.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 minus_restores_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::Char('-'), 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::Char('-'), 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::Char('-'), 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<TestBackend>| -> 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::<Vec<_>>()
};
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::<String>()
.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::<Vec<_>>(),
["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::<usize>(),
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::<Vec<_>>()
.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::<usize>(),
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::<Vec<_>>(),
["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::<Vec<_>>(),
["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,
duration: 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,
duration: 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();
}
#[test]
fn escape_discards_checks_without_consuming_view_history() {
let mut app = app(&["tubular"]);
app.views[0].loaded = true;
app.views[1].loaded = true;
app.switch_view(1);
app.views[1].checked.insert("jNQXAC9IVRw".into());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.views[1].checked.is_empty());
assert_eq!(app.view_index, 1);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.view_index, 1);
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
assert_eq!(app.view_index, 0);
}