refactor: extract unit tests into tests directory

This commit is contained in:
2026-10-18 00:38:34 +02:00
parent d9c740c506
commit 3e701c99c9
19 changed files with 11353 additions and 11383 deletions
+5836
View File
File diff suppressed because it is too large Load Diff
+1195
View File
File diff suppressed because it is too large Load Diff
+559
View File
@@ -0,0 +1,559 @@
use super::*;
use toml_edit::DocumentMut;
fn configured(source: &str) -> io::Result<Bindings> {
let document = source.parse::<DocumentMut>().unwrap();
Bindings::from_toml(document.get("keys"))
}
fn press(
bindings: &Bindings,
context: Context,
code: KeyCode,
modifiers: KeyModifiers,
) -> Option<Action> {
bindings.resolve(context, KeyEvent::new(code, modifiers))
}
#[test]
fn only_section_headers_are_bold_in_the_grouped_overview() {
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Modifier,
widgets::{Paragraph, Widget, Wrap},
};
let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap();
for width in [21, 64] {
let area = Rect::new(0, 0, width, 16);
let mut buffer = Buffer::empty(area);
Paragraph::new(bindings.help_text())
.wrap(Wrap { trim: false })
.render(area, &mut buffer);
assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD));
for y in 2..area.height {
for x in 0..width {
assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD));
}
}
let binding_visible = (2..area.height).any(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.contains("F24")
});
assert!(
binding_visible,
"custom shortcut must remain visible after wrapping"
);
}
}
#[test]
fn contextual_cycle_and_direct_preview_bindings() {
let defaults = Bindings::default();
for (context, action) in [
(Context::Normal, Action::InfoOverview),
(Context::Info, Action::InfoDescription),
(Context::Description, Action::InfoOverview),
(Context::CollectionNormal, Action::InfoOverview),
(Context::CollectionInfo, Action::InfoDescription),
(Context::CollectionDescription, Action::InfoOverview),
] {
assert_eq!(
press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE),
Some(action)
);
}
let bindings =
configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n").unwrap();
for context in VIEW {
assert_eq!(
press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE),
Some(Action::InfoOverview)
);
assert_eq!(
press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE),
Some(Action::InfoDescription)
);
}
assert_eq!(
press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE),
Some(Action::InfoHide)
);
assert_eq!(
press(
&defaults,
Context::Description,
KeyCode::Char('-'),
KeyModifiers::NONE
),
Some(Action::ViewPrevious)
);
assert_eq!(
press(
&defaults,
Context::Description,
KeyCode::Down,
KeyModifiers::NONE
),
Some(Action::DescriptionNext)
);
assert_eq!(
press(
&defaults,
Context::Description,
KeyCode::Char('j'),
KeyModifiers::NONE
),
Some(Action::VideoNext)
);
assert_eq!(
press(
&defaults,
Context::Description,
KeyCode::Char('j'),
KeyModifiers::CONTROL
),
Some(Action::DescriptionNext)
);
assert_eq!(
press(
&defaults,
Context::SearchInput,
KeyCode::Char('d'),
KeyModifiers::NONE
),
None
);
assert_eq!(
press(
&defaults,
Context::SelectorNormal,
KeyCode::Char('j'),
KeyModifiers::NONE
),
Some(Action::SelectorNext)
);
assert_eq!(
press(
&defaults,
Context::SelectorInput,
KeyCode::Char('j'),
KeyModifiers::NONE
),
None
);
for context in ALL {
assert_eq!(
press(
&defaults,
*context,
KeyCode::Char('c'),
KeyModifiers::CONTROL
),
Some(Action::Interrupt)
);
}
}
#[test]
fn modifier_normalization_is_exact_and_allocation_free() {
let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap();
for (code, modifiers) in [
(KeyCode::Char('I'), KeyModifiers::NONE),
(KeyCode::Char('I'), KeyModifiers::SHIFT),
(KeyCode::Char('i'), KeyModifiers::SHIFT),
] {
assert_eq!(
press(&bindings, Context::Normal, code, modifiers),
Some(Action::InfoOverview)
);
}
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('i'),
KeyModifiers::NONE
),
None
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('I'),
KeyModifiers::ALT
),
None
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('?'),
KeyModifiers::SHIFT
),
Some(Action::Help)
);
for (code, modifiers) in [
(KeyCode::BackTab, KeyModifiers::NONE),
(KeyCode::BackTab, KeyModifiers::SHIFT),
(KeyCode::Tab, KeyModifiers::SHIFT),
] {
assert_eq!(
press(&bindings, Context::Normal, code, modifiers),
Some(Action::ViewTabPrevious)
);
}
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('+'),
KeyModifiers::CONTROL
),
Some(Action::ViewNext)
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('+'),
KeyModifiers::NONE
),
None
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::F(24),
KeyModifiers::ALT
),
Some(Action::ViewRefresh)
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('界'),
KeyModifiers::SUPER
),
Some(Action::SearchEdit)
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::ALT
),
None
);
}
#[test]
fn aliases_replace_defaults_and_can_disable_actions() {
let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
assert_eq!(quoted, bindings);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('r'),
KeyModifiers::NONE
),
None
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('q'),
KeyModifiers::NONE
),
None
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('x'),
KeyModifiers::NONE
),
Some(Action::ViewRefresh)
);
assert_eq!(
press(
&bindings,
Context::Normal,
KeyCode::Char('y'),
KeyModifiers::CONTROL
),
Some(Action::ViewRefresh)
);
}
#[test]
fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
let bindings = Bindings::default();
for &context in ALL {
for (key, action) in [
('x', Action::CollectionRemoveSelected),
('X', Action::CollectionRemoveBulk),
] {
assert_eq!(
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
COLLECTION.contains(&context).then_some(action),
);
}
assert_eq!(
press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE),
None,
);
}
for (ordinary, collection) in [
(Context::Normal, Context::CollectionNormal),
(Context::Info, Context::CollectionInfo),
(Context::Description, Context::CollectionDescription),
] {
for &(key, action) in &bindings.contexts[ordinary as usize] {
assert_eq!(
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
Some(action),
"{ordinary:?} / {collection:?}: {key:?}",
);
}
assert_eq!(
bindings.contexts[collection as usize].len(),
bindings.contexts[ordinary as usize].len() + 2,
);
}
}
#[test]
fn collection_overrides_aliases_normalize_and_disable() {
let bindings = configured(
"[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n",
)
.unwrap();
let quoted = configured(
"[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n",
).unwrap();
assert_eq!(bindings, quoted);
assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x");
assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B");
let help = bindings.help_text().to_string();
assert!(help.contains("Alt+x"));
assert!(help.lines().any(|line| line.trim_start().starts_with("B ")));
for &context in ALL {
for (code, modifiers, action) in [
(
KeyCode::Char('x'),
KeyModifiers::ALT,
Action::CollectionRemoveSelected,
),
(
KeyCode::Char('b'),
KeyModifiers::SHIFT,
Action::CollectionRemoveBulk,
),
(
KeyCode::Char('B'),
KeyModifiers::NONE,
Action::CollectionRemoveBulk,
),
] {
assert_eq!(
press(&bindings, context, code, modifiers),
COLLECTION.contains(&context).then_some(action),
);
}
for key in ['x', 'X'] {
assert_eq!(
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
None
);
}
}
let disabled = configured(
"[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n",
).unwrap();
for &context in ALL {
for key in ['x', 'X'] {
assert_eq!(
press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE),
VIEW.contains(&context).then_some(Action::ViewRefresh),
);
}
}
}
#[test]
fn collection_contexts_validate_inherited_binding_conflicts() {
for source in [
"[keys]\nview.refresh = 'x'",
"[keys]\nview.watch_later.browse = 'X'",
"[keys]\nview.saved.browse = 'X'",
"[keys]\nview.collection.remove.selected = 'q'",
"[keys]\nview.collection.remove.bulk = 'down'",
] {
let error = configured(source).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("Collection"), "{error}");
}
}
#[test]
fn collection_browse_shortcuts_are_distinct_and_view_scoped() {
let defaults = Bindings::default();
let overrides = configured(
"[keys.view.watch_later]\ncategory = ['OpTiOn+b', 'alt+b']\n[keys]\n'view.saved.category' = []\n",
)
.unwrap();
for &context in ALL {
for (key, modifiers, action) in [
('W', KeyModifiers::NONE, Action::WatchLaterBrowse),
('w', KeyModifiers::SHIFT, Action::WatchLaterBrowse),
('V', KeyModifiers::NONE, Action::SavedVideoBrowse),
('v', KeyModifiers::SHIFT, Action::SavedVideoBrowse),
('w', KeyModifiers::ALT, Action::WatchLaterBrowseCategory),
('v', KeyModifiers::ALT, Action::SavedVideoBrowseCategory),
] {
assert_eq!(
press(&defaults, context, KeyCode::Char(key), modifiers),
VIEW.contains(&context).then_some(action),
);
}
assert_eq!(
press(&overrides, context, KeyCode::Char('b'), KeyModifiers::ALT),
VIEW.contains(&context)
.then_some(Action::WatchLaterBrowseCategory),
);
for key in ['w', 'v'] {
assert_eq!(
press(&overrides, context, KeyCode::Char(key), KeyModifiers::ALT),
None,
);
}
for key in ['W', 'V'] {
assert_eq!(
press(&defaults, context, KeyCode::Char(key), KeyModifiers::ALT),
None,
);
}
}
assert_eq!(overrides.label(Action::WatchLaterBrowseCategory), "Alt+b");
}
#[test]
fn collection_browsing_and_save_overrides_retain_contexts() {
let bindings = configured(
"[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\ncategory = []\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\ncategory = []\n[keys.view.collection.remove]\nbulk = []\n",
).unwrap();
for &context in ALL {
for (key, modifiers, action, enabled) in [
(
'w',
KeyModifiers::ALT,
Action::WatchLaterSave,
VIEW.contains(&context),
),
(
'v',
KeyModifiers::ALT,
Action::SavedVideoSave,
VIEW.contains(&context),
),
(
'x',
KeyModifiers::SHIFT,
Action::WatchLaterBrowse,
VIEW.contains(&context),
),
(
'f',
KeyModifiers::CONTROL,
Action::WatchLaterFilter,
matches!(context, Context::Normal | Context::CollectionNormal),
),
(
'b',
KeyModifiers::ALT,
Action::SavedVideoBrowse,
VIEW.contains(&context),
),
] {
assert_eq!(
press(&bindings, context, KeyCode::Char(key), modifiers),
enabled.then_some(action)
);
}
}
}
#[test]
fn obsolete_removal_names_are_unknown() {
for name in [
"view.watch_later.remove",
"video.watch_later.remove",
"video.saved.remove",
] {
let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("unknown action"), "{error}");
assert!(error.to_string().contains(name), "{error}");
}
}
#[test]
fn invalid_entries_and_collisions_have_actionable_errors() {
for (source, path) in [
("[keys]\nunknown = 'x'", "keys.unknown"),
("[keys.unknown]", "keys.unknown"),
("keys = []", "keys"),
("[keys]\nview = 'x'", "keys.view"),
("[keys]\nview.refresh = 1", "keys.view.refresh"),
("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"),
("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"),
(
"[keys]\nview.refresh = 'ctrl+control+x'",
"keys.view.refresh",
),
("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"),
("[keys]\nview.refresh = 'f25'", "keys.view.refresh"),
("[keys]\nview.refresh = ''", "keys.view.refresh"),
] {
let error = configured(source).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains(path), "{error}");
}
for source in [
"[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'",
"[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'",
] {
let error = configured(source).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(
error.to_string().contains("keys.video.info.overview"),
"{error}"
);
}
for source in [
"[keys]\nview.refresh = 'q'",
"[keys]\nview.refresh = 'ctrl+c'",
"[keys]\nsearch.edit = 'shift+g'",
] {
let error = configured(source).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
let collision = configured("[keys]\nview.refresh = 'q'")
.unwrap_err()
.to_string();
assert!(collision.contains("view.refresh") && collision.contains("app.quit"));
// Disjoint contexts can use the same key, including an overridden input command.
assert!(configured("[keys]\ninput.submit = 'q'").is_ok());
}
+108
View File
@@ -0,0 +1,108 @@
use super::*;
#[test]
fn source_projection_rejects_unsafe_or_unsupported_playback_contracts() {
let sources = serde_json::json!({
"episodeNumber": 1,
"config": { "sun": { "player": "native", "proxy": false } },
"tracks": [{ "track": "ssub", "providers": [{
"provider": "sun",
"servers": [{
"headers": { "Origin": "https://ultracloud.cc", "Referer": "https://ultracloud.cc/" },
"streams": [{ "url": "https://vault.keeply.top/master.m3u8?token=secret", "format": "hls", "embed": null }]
}],
"subtitles": [{
"file": "https://vault.keeply.top/english.jpeg", "format": "vtt",
"language": "en", "label": "English", "default": true
}]
}] }]
});
let identity = Identity::new("catalog".into(), 1).unwrap();
let source = SourceDocument::from_sources(&sources, identity.clone(), "Episode 1").unwrap();
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!(
Identity::new("catalog".into(), 2).unwrap().key(),
source.identity.key()
);
for key in [
"miruro:catalog:01",
"miruro:catalog:0",
"miruro:catalog:2147483648",
"miruro:catalog/:1",
] {
assert!(Identity::from_key(key).is_err());
}
for (pointer, value) in [
("/episodeNumber", serde_json::json!(49)),
("/config/sun/proxy", serde_json::json!({"segments": true})),
(
"/tracks/0/providers/0/servers/0/streams/0/url",
serde_json::json!("file:///secret"),
),
(
"/tracks/0/providers/0/servers/0/streams/0/embed",
serde_json::json!({"url": "https://embed.example"}),
),
(
"/tracks/0/providers/0/servers/0/headers/origin",
serde_json::json!("secret"),
),
(
"/tracks/0/providers/0/servers/0/headers/Referer",
serde_json::json!("secret\r\nX-Injected: true"),
),
] {
let mut invalid_sources = sources.clone();
if let Some(field) = invalid_sources.pointer_mut(pointer) {
*field = value;
} else {
invalid_sources["tracks"][0]["providers"][0]["servers"][0]["headers"]["origin"] = value;
}
let error = SourceDocument::from_sources(&invalid_sources, identity.clone(), "Episode 1")
.unwrap_err();
assert!(!error.to_string().contains("secret"));
}
let mut invalid_source = source;
invalid_source
.subtitles
.push(invalid_source.subtitles[0].clone());
assert!(invalid_source.validate().is_err());
invalid_source.subtitles[1].default = false;
invalid_source.subtitles[1].format = "srt".into();
assert!(invalid_source.validate().is_err());
}
#[test]
fn episodes_resolve_by_promise_id_with_independent_tables_or_fail_explicitly() {
let initial = r#"{"type":"data","nodes":[null,{"type":"data","data":[{"detail":1,"episodes":4},{"id":2,"episode_count":3},"series",12,["Promise",5],9]}]}"#;
let unrelated = r#"{"type":"chunk","id":4,"data":["unrelated"]}"#;
let episode_chunk = r#"{"type":"chunk","id":9,"data":[[1],{"episode_number":2,"absolute_number":3,"title":4,"canon_type":5},1,49,"Locusts",null]}"#;
let detail = decode_detail(&format!("{initial}\n{unrelated}\n{episode_chunk}\n")).unwrap();
assert_eq!(detail.media["episode_count"], 12);
assert_eq!(detail.episodes[0]["episode_number"], 1);
assert_eq!(detail.episodes[0]["absolute_number"], 49);
assert_eq!(detail.episodes[0]["title"], "Locusts");
assert!(detail.episodes[0]["canon_type"].is_null());
for chunk in [
"", // The promised episodes never arrive.
r#"{"type":"chunk","id":9,"error":["failed"]}"#,
r#"{"type":"chunk","id":9,"data":[[999]]}"#,
r#"{"type":"chunk","id":9,"data":[[0]]}"#, // Reference cycle.
r#"{"type":"chunk","id":9,"data":[["Date","2026-10-12"]]}"#,
] {
assert_eq!(
decode_detail(&format!("{initial}\n{chunk}"))
.err()
.unwrap()
.kind(),
io::ErrorKind::InvalidData
);
}
assert_eq!(
decode_detail(r#"{"type":"redirect","location":"/"}"#)
.err()
.unwrap()
.kind(),
io::ErrorKind::NotFound
);
}
+69
View File
@@ -0,0 +1,69 @@
use super::*;
#[test]
fn catalog_and_actual_episode_numbers_remain_distinct() {
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null});
let rows = catalog(vec![media.clone()]).unwrap();
assert_eq!(rows[0].id, "opaque_ID");
assert!(rows[0].metadata["episode_counts"]["sub"].is_null());
assert!(catalog(vec![media.clone(), media.clone()]).is_err());
let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500});
let detail = || Detail {
media: media.clone(),
episodes: vec![episode.clone()],
};
let data = series("opaque_ID", detail()).unwrap();
assert_eq!(data.episodes.len(), 1);
assert_eq!(data.episodes[0].identity.episode, 3);
assert_eq!(data.episodes[0].identity.media_id, "opaque_ID");
assert_eq!(data.episodes[0].duration_seconds, Some(1500));
assert_eq!(
data.episodes[0].synopsis.as_deref(),
Some("Actual synopsis")
);
assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none());
assert!(series("other", detail()).is_err());
assert!(
series(
"opaque_ID",
Detail {
media: media.clone(),
episodes: vec![episode.clone(), episode]
}
)
.is_err()
);
assert!(
series(
"opaque_ID",
Detail {
media,
episodes: vec![serde_json::json!({"absolute_number":49})]
}
)
.is_err()
);
assert!(slug("薬屋").is_err());
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
assert_eq!(
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
"a-b-c"
);
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
assert!(slug("!!薬屋!!").is_err());
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
"AB"
);
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
"BC"
);
assert_eq!(
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
""
);
}
+683
View File
@@ -0,0 +1,683 @@
use super::*;
use ratatui::crossterm::event::KeyModifiers;
use std::{
collections::BTreeMap,
fs,
io::Write,
sync::atomic::{AtomicU64, Ordering},
};
use tubular::miruro::{Detail, SourceDocument};
struct Directory(PathBuf);
impl Directory {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let directory = std::env::temp_dir().join(format!(
"tubular-miruro-ui-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir(&directory).unwrap();
Self(directory)
}
fn browser(&self, query: Option<&str>) -> Browser {
let path = self.0.join("config.toml");
fs::write(&path, "").unwrap();
let options = Options {
player: self.0.join("missing-player").into_os_string(),
default_config: false,
miruro: true,
query: query.map(str::to_owned),
};
Browser::new(
options,
path.clone(),
config::Config::default(),
State::open(&path).unwrap(),
)
}
}
impl Drop for Directory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn media(id: &str) -> Media {
miruro_browser::catalog(vec![serde_json::json!({"id":id, "title":{"english":format!("Series {id}")}, "episode_count":12, "description":"Series synopsis", "episode_counts":{"sub":null}})]).unwrap().remove(0)
}
fn series(media: &Media) -> miruro_browser::Series {
let mut metadata = media.metadata.clone();
metadata.as_object_mut().unwrap().remove("episode_count");
miruro_browser::series(&media.id, Detail { media: metadata, episodes: vec![
serde_json::json!({"episode_number":3, "absolute_number":49, "title":"Actual episode", "synopsis":"Episode synopsis", "aired_on":"2026-10-02", "duration_seconds":1500}),
serde_json::json!({"episode_number":7, "absolute_number":53, "title":null, "duration_seconds":null}),
] }).unwrap()
}
fn source(video: &Video, token: &str) -> SourceDocument {
SourceDocument {
version: 1,
url: format!("https://vault.keeply.top/master.m3u8?token={token}"),
headers: BTreeMap::new(),
identity: Identity::from_key(&video.id).unwrap(),
title: Some(video.title.clone()),
subtitles: Vec::new(),
}
}
fn catalog_results(browser: &mut Browser) {
browser.accept(
browser.generation,
Ok(Response::Catalog(vec![media("first"), media("second")])),
);
}
fn enter_series(browser: &mut Browser) {
browser.open();
let Content::Series { media, .. } = &browser.screen().content else {
panic!("series screen");
};
let result = series(media);
browser.accept(browser.generation, Ok(Response::Series(result)));
}
fn sources(directory: &Directory) -> Vec<Value> {
let path = directory.0.join("playback-sessions");
if !path.exists() {
return Vec::new();
}
fs::read_dir(path)
.unwrap()
.map(|entry| {
serde_json::from_slice(&fs::read(entry.unwrap().path().join("source.json")).unwrap())
.unwrap()
})
.collect()
}
#[test]
fn metadata_popup_scrolls_and_closes_without_moving_the_episode_selection() {
use ratatui::{Terminal, backend::TestBackend};
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
browser
.screen_mut()
.overview
.push_str(&"\nMore synopsis".repeat(40));
browser.screen_mut().overview.push_str("\nTAIL");
browser.screen_mut().update_description();
let selected = browser.screen().selected_video().unwrap().id;
for (width, height) in [(120, 40), (60, 24), (24, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Info);
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Description);
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
let popup = crate::preview_area(terminal.backend().buffer().area);
let popup_text = |terminal: &Terminal<TestBackend>| -> String {
(popup.y + 1..popup.bottom() - 1)
.flat_map(|y| {
(popup.x + 1..popup.right() - 1)
.map(move |x| terminal.backend().buffer()[(x, y)].symbol())
})
.collect()
};
assert!(popup_text(&terminal).contains("Episode 3"));
browser.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert_eq!(browser.screen().description_scroll, 1);
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = u16::MAX;
terminal
.draw(|frame| browser.draw(frame, Duration::ZERO))
.unwrap();
assert!(popup_text(&terminal).contains("TAIL"));
browser.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::Info);
browser.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(browser.preview.is_none());
assert_eq!(browser.screen().selected_video().unwrap().id, selected);
browser.screen_mut().description_scroll = 0;
}
browser.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:first:7"
);
}
#[test]
fn catalog_series_back_forward_keep_native_query_selection_and_actual_episodes() {
let directory = Directory::new();
let mut browser = directory.browser(Some("apothecary"));
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "apothecary"));
catalog_results(&mut browser);
browser.select(Action::VideoNext);
enter_series(&mut browser);
assert_eq!(browser.screen().len(), 2); // Neither planned 12 nor absolute 49 creates rows.
assert!(
matches!(&browser.screen().content, Content::Series { media, .. } if media.metadata["episode_count"] == 12 && media.metadata["episode_counts"]["sub"].is_null())
);
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:second:3"
);
browser.select(Action::VideoNext);
browser.save(CollectionKind::Saved);
browser.history(false);
assert_eq!(browser.screen().selection.selected(), Some(1));
assert!(
matches!(&browser.screen().content, Content::Catalog { query, media, .. } if query == "apothecary" && media.len() == 2)
);
assert!(browser.pending.is_none());
browser.history(true);
assert_eq!(
browser.screen().selected_video().unwrap().id,
"miruro:second:7"
);
assert!(browser.pending.is_none());
assert!(
browser
.state
.contains(CollectionKind::Saved, "miruro:second:7")
);
}
#[test]
fn latest_intent_and_navigation_reject_stale_catalog_series_and_sources() {
let directory = Directory::new();
let mut browser = directory.browser(Some("old"));
let old = browser.generation;
browser.edit_search();
browser.input = Some("latest".into());
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(&browser.pending, Some(Request::Search(query)) if query == "latest"));
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
assert_eq!(browser.screen().len(), 0);
catalog_results(&mut browser);
browser.open();
let stale = browser.generation;
let Content::Series { media, .. } = &browser.screen().content else {
panic!("series");
};
let data = series(media);
browser.history(false);
browser.accept(stale, Ok(Response::Series(data)));
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
browser.history(true);
let Content::Series { media, .. } = &browser.screen().content else {
panic!("series");
};
let data = series(media);
browser.accept(browser.generation, Ok(Response::Series(data)));
let video = browser.screen().selected_video().unwrap();
browser.open();
let stale = browser.generation;
browser.history(false);
browser.accept(
stale,
Ok(Response::Source(
video.clone(),
Box::new(source(&video, "stale")),
)),
);
assert!(sources(&directory).is_empty());
browser.history(true);
browser.open();
let stale = browser.generation;
browser.refresh();
browser.accept(
stale,
Ok(Response::Source(
video.clone(),
Box::new(source(&video, "refreshed-away")),
)),
);
assert!(sources(&directory).is_empty());
}
#[test]
fn manual_watch_status_refreshes_filters_and_preserves_other_providers() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(browser.state.videos(CollectionKind::Watching).is_empty());
assert!(!browser.bindings().iter().any(|(action, _)| {
matches!(
action,
Action::WatchStatusWatching | Action::WatchStatusWatched
)
}));
enter_series(&mut browser);
let episode = browser.screen().selected_video().unwrap();
let mut youtube = episode.clone();
youtube.id = "jNQXAC9IVRw".into();
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
youtube.uploader_id = Some("@jawed".into());
browser
.state
.save(CollectionKind::Watching, &youtube)
.unwrap();
browser.save(CollectionKind::WatchLater);
browser.preview = Some(PreviewMode::Info);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert!(
browser
.state
.contains(CollectionKind::Watching, &episode.id)
);
assert!(!browser.state.is_watched(&episode.id));
browser.preview = None;
browser.collection(CollectionKind::Watching);
browser.open();
assert!(browser.pending.is_some());
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
assert!(browser.state.is_watched(&episode.id));
assert_eq!(browser.screen().len(), 0);
assert!(browser.pending.is_none());
browser.collection(CollectionKind::WatchLater);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 1);
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
assert!(!browser.state.is_watched(&episode.id));
assert!(
browser
.state
.contains(CollectionKind::Watching, &episode.id)
);
assert!(
browser
.state
.contains(CollectionKind::Watching, &youtube.id)
);
assert!(!browser.state.is_watched(&youtube.id));
let state = State::open(&browser.config_path).unwrap();
assert!(state.contains(CollectionKind::Watching, &episode.id));
assert!(state.contains(CollectionKind::WatchLater, &episode.id));
let overrides: toml_edit::DocumentMut =
"[keys.video.status]\nwatched = 'alt+m'\n".parse().unwrap();
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
browser.collection(CollectionKind::Watching);
browser.key(KeyEvent::new(KeyCode::Char('M'), KeyModifiers::NONE));
assert!(!browser.state.is_watched(&episode.id));
browser.key(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::ALT));
assert!(browser.state.is_watched(&episode.id));
assert!(state.contains(CollectionKind::Watching, &youtube.id));
}
#[test]
fn offline_collections_and_filtered_bulk_removal_never_touch_youtube() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
let episode = browser.screen().selected_video().unwrap();
let mut youtube = episode.clone();
youtube.id = "jNQXAC9IVRw".into();
youtube.title = "YouTube title".into();
youtube.channel = Some("jawed".into());
youtube.channel_id = Some("UC4QobU6STFB0P71PMvOGN5A".into());
youtube.uploader_id = Some("@jawed".into());
for kind in [
CollectionKind::Saved,
CollectionKind::WatchLater,
CollectionKind::Watching,
] {
browser.state.save(kind, &episode).unwrap();
browser.state.save(kind, &youtube).unwrap();
}
browser.collection(CollectionKind::Saved);
assert_eq!(browser.screen().len(), 1);
browser.open();
assert!(matches!(&browser.pending, Some(Request::Source(video)) if video.id == episode.id));
browser.remove(true);
assert_eq!(
browser.removal.as_ref().unwrap().1.as_slice(),
std::slice::from_ref(&episode.id)
);
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(browser.state.contains(CollectionKind::Saved, &youtube.id));
assert!(!browser.state.contains(CollectionKind::Saved, &episode.id));
assert!(
browser
.state
.contains(CollectionKind::WatchLater, &episode.id)
);
let (_, report) = browser
.state
.miruro_session(&episode, &source(&episode, "progress"))
.unwrap();
let mut output = fs::OpenOptions::new();
output.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
output.mode(0o600);
}
let mut output = output.open(report).unwrap();
writeln!(output, "{}", serde_json::json!({"version":2,"event":"ended","media_identity":Identity::from_key(&episode.id).unwrap(),"position_seconds":95.0,"duration_seconds":100.0,"completion_eligible":true})).unwrap();
assert!(browser.state.poll_progress().unwrap());
browser.rebuild_collections();
assert!(browser.state.is_watched(&episode.id));
assert!(
!browser
.state
.contains(CollectionKind::Watching, &episode.id)
);
for kind in [CollectionKind::Watching, CollectionKind::WatchLater] {
browser.collection(kind);
if matches!(kind, CollectionKind::WatchLater) {
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 0);
browser.key(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE));
assert_eq!(browser.screen().len(), 1);
browser.remove(true);
browser.key(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert!(browser.state.is_watched(&episode.id));
}
assert!(browser.state.contains(kind, &youtube.id));
}
let state = State::open(&browser.config_path).unwrap();
assert!(state.contains(CollectionKind::Saved, &youtube.id));
assert!(state.contains(CollectionKind::Watching, &youtube.id));
}
#[test]
fn selected_episode_and_offline_reopen_write_fresh_validated_source_sessions() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
enter_series(&mut browser);
let first = browser.screen().selected_video().unwrap();
browser.open();
let stale = browser.generation;
browser.select(Action::VideoNext);
browser.accept(
stale,
Ok(Response::Source(
first.clone(),
Box::new(source(&first, "not-selected")),
)),
);
assert!(sources(&directory).is_empty());
let selected = browser.screen().selected_video().unwrap();
assert_eq!(Identity::from_key(&selected.id).unwrap().episode, 7);
browser.save(CollectionKind::Saved);
browser.collection(CollectionKind::Saved);
browser.open();
browser.pending = None;
browser.accept(
browser.generation,
Ok(Response::Source(
selected.clone(),
Box::new(source(&first, "wrong-identity")),
)),
);
assert!(sources(&directory).is_empty());
browser.open();
browser.pending = None;
let mut invalid = source(&selected, "private-token");
invalid
.headers
.insert("X-Invalid".into(), "private-header\nvalue".into());
browser.accept(
browser.generation,
Ok(Response::Source(selected.clone(), Box::new(invalid))),
);
assert!(sources(&directory).is_empty());
assert!(!browser.status.contains("private-token"));
assert!(!browser.status.contains("private-header"));
for token in ["fresh-one", "fresh-two"] {
browser.open();
assert!(
matches!(&browser.pending, Some(Request::Source(video)) if video.id == selected.id && video.title == selected.title)
);
browser.pending = None; // Deliver the real source response without a network request.
browser.accept(
browser.generation,
Ok(Response::Source(
selected.clone(),
Box::new(source(&selected, token)),
)),
);
}
let written = sources(&directory);
assert_eq!(written.len(), 2);
assert!(
written
.iter()
.all(|source| source["identity"]["episode"] == 7
&& source["identity"]["media_id"] == "first")
);
assert!(
written
.iter()
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-one"))
);
assert!(
written
.iter()
.any(|source| source["url"].as_str().unwrap().ends_with("fresh-two"))
);
assert!(!browser.state.is_watched(&selected.id)); // Spawn failure/CLI completion is not progress.
assert!(
!browser
.state
.contains(CollectionKind::Watching, &selected.id)
);
assert!(browser.screen().error.is_some()); // Native launch errors remain visible.
}
#[test]
fn configured_modal_keys_and_inputs_do_not_leak_to_main_actions() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
let overrides: toml_edit::DocumentMut =
"[keys]\nchannel.tags = 'f2'\ntags.new = 'f3'\ntags.save = 'f4'\ninput.submit = 'f5'\n"
.parse()
.unwrap();
browser.config.keys = crate::keys::Bindings::from_toml(overrides.get("keys")).unwrap();
browser.key(KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagList);
browser.key(KeyEvent::new(KeyCode::F(3), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagInput);
browser.paste("Safe");
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
browser.key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
assert_eq!(browser.context(), Context::TagList);
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(browser.state.shows().is_empty());
assert_eq!(browser.index, 0);
browser.key(KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE));
assert_eq!(
browser.state.shows()[0].tags,
BTreeSet::from(["Safe".into()])
);
assert!(browser.modal.is_none());
}
#[test]
fn checked_show_categories_survive_hover_and_filter_changes() {
let directory = Directory::new();
let mut browser = directory.browser(None);
for (id, tag) in [("first", "Drama"), ("second", "Favorites")] {
browser
.state
.save_show(&media(id), &[tag.into()].into())
.unwrap();
}
for filter in [None, Some("no matching category")] {
browser.category_picker();
for code in [KeyCode::Char('j'), KeyCode::Char(' '), KeyCode::Char('k')] {
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
}
if let Some(filter) = filter {
browser.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
browser.paste(filter);
}
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let Content::Library { shows, .. } = &browser.screen().content else {
panic!("Checked categories must open even without a hovered match");
};
assert_eq!(
shows
.iter()
.map(|show| show.media.id.as_str())
.collect::<Vec<_>>(),
["first"]
);
}
}
#[test]
fn saved_shows_stage_cancel_reopen_and_remove_without_touching_episodes() {
let directory = Directory::new();
let mut browser = directory.browser(Some("anime"));
catalog_results(&mut browser);
let key = |browser: &mut Browser, code| {
browser.key(KeyEvent::new(code, KeyModifiers::NONE));
};
key(&mut browser, KeyCode::Char('s'));
key(&mut browser, KeyCode::Char('n'));
browser.paste("Drama");
key(&mut browser, KeyCode::Enter);
key(&mut browser, KeyCode::Esc);
assert!(browser.state.shows().is_empty());
key(&mut browser, KeyCode::Char('s'));
key(&mut browser, KeyCode::Enter);
assert!(browser.state.shows()[0].tags.is_empty());
enter_series(&mut browser);
browser.select(Action::VideoNext);
browser.save(CollectionKind::Saved);
let episode = browser.screen().selected_video().unwrap().id;
key(&mut browser, KeyCode::Char('s'));
for tag in ["Drama", "Favorites"] {
key(&mut browser, KeyCode::Char('n'));
browser.paste(tag);
key(&mut browser, KeyCode::Enter);
}
key(&mut browser, KeyCode::Enter);
assert_eq!(browser.state.shows()[0].media.id, "first");
browser
.state
.save_show(&media("second"), &BTreeSet::from(["Drama".into()]))
.unwrap();
assert_eq!(
library_shows(
&browser.state,
&BTreeSet::from(["Drama".into(), "Favorites".into()])
)
.len(),
2
);
browser.state = State::open(&browser.config_path).unwrap();
key(&mut browser, KeyCode::Char('c'));
key(&mut browser, KeyCode::Char('i'));
browser.paste("Favorites");
key(&mut browser, KeyCode::Enter);
assert_eq!(browser.screen().len(), 1);
browser.open();
assert!(
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "first")
);
browser.history(false);
browser.library(BTreeSet::new());
key(&mut browser, KeyCode::Char('x'));
assert_eq!(browser.screen().len(), 1);
browser.history(false);
assert_eq!(browser.screen().len(), 0);
assert!(browser.state.contains(CollectionKind::Saved, &episode));
assert_eq!(
State::open(&browser.config_path).unwrap().shows()[0]
.media
.id,
"second"
);
}
#[test]
fn empty_errors_and_query_controls_do_not_create_results_or_commands() {
let directory = Directory::new();
let mut browser = directory.browser(None);
assert!(browser.pending.is_none());
browser.open();
assert_eq!(browser.index, 0);
browser.edit_search();
browser.paste("Anime\nquery\t\u{1b}[31m");
browser.key(KeyEvent::new(KeyCode::Char('\u{7}'), KeyModifiers::NONE));
assert!(
browser
.input
.as_ref()
.unwrap()
.chars()
.all(|ch| !ch.is_control())
);
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let old = browser.generation;
browser.accept(old, Err(io::Error::other("Catalog request failed")));
assert!(browser.screen().error.is_some());
assert_eq!(browser.screen().len(), 0);
browser.search();
browser.accept(browser.generation, Ok(Response::Catalog(Vec::new())));
assert!(browser.screen().error.is_none());
assert!(matches!(
browser.screen().content,
Content::Catalog { searched: true, .. }
));
browser.edit_search();
browser.input = Some(" ".into());
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(browser.pending.is_none());
assert!(matches!(
browser.screen().content,
Content::Catalog {
searched: false,
..
}
));
browser.accept(old, Ok(Response::Catalog(vec![media("stale")])));
assert_eq!(browser.screen().len(), 0);
assert!(browser.screen().rows.is_empty());
fs::write(
&browser.config_path,
"[keys]\nsearch.edit = 'f8'\napp.quit = 'f9'\n",
)
.unwrap();
browser.config = config::load(&browser.config_path).unwrap();
browser.key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
assert!(browser.input.is_some());
browser.input = Some("safe".into());
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let metadata = miruro_browser::catalog(vec![serde_json::json!({
"id":"safe", "title":{"english":"薬屋 Café\u{1b}[31m"},
"description":"<p>First\u{1b}[31m</p><p>Second<br/>Third</p>"
})])
.unwrap();
browser.accept(browser.generation, Ok(Response::Catalog(metadata)));
assert!(
browser
.screen()
.rows
.iter()
.all(|row| row.title.chars().all(|ch| !ch.is_control()))
);
assert!(
browser
.screen()
.description
.chars()
.all(|ch| !ch.is_control() || ch == '\n')
);
assert!(!browser.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)));
assert!(browser.key(KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)));
}
+331
View File
@@ -0,0 +1,331 @@
use super::*;
#[test]
fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() {
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
let mut thumbnail = Thumbnail::new();
thumbnail.selected = Some("fixture".into());
let area = Rect::new(2, 2, 80, 8);
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
let mut loading_width = 0;
terminal
.draw(|frame| loading_width = thumbnail.draw(frame, area))
.unwrap();
let (_decode_tx, decode_rx) = mpsc::channel();
thumbnail.decoder = Some(decode_rx);
let mut decoding_width = 0;
terminal
.draw(|frame| decoding_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, decoding_width);
thumbnail.decoder = None;
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
let (replies_tx, replies_rx) = mpsc::channel();
thumbnail.image = Some(ThumbnailImage {
id: "fixture".into(),
protocol: ThreadProtocol::new(
requests_tx,
Some(
thumbnail
.picker
.new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)),
),
),
replies: replies_rx,
complete: false,
});
let mut preparing_width = 0;
terminal
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, preparing_width);
terminal
.draw(|frame| preparing_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, preparing_width);
replies_tx
.send(Ok(requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.resize_encode()
.unwrap()))
.unwrap();
let mut ready_width = 0;
terminal
.draw(|frame| ready_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(
loading_width, ready_width,
"right-hand content must not move when the image finishes loading"
);
thumbnail.cancel();
thumbnail.select("fixture");
terminal
.draw(|frame| ready_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, ready_width);
replies_tx
.send(Err("fixture renderer failure".into()))
.unwrap();
let mut error_width = 0;
terminal
.draw(|frame| error_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, error_width);
assert!(thumbnail.error.is_some());
}
#[test]
fn portrait_cover_keeps_its_slot_and_actual_aspect_when_ready_and_cached() {
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
let mut thumbnail = Thumbnail::new();
let url = "https://example.test/cover.png";
thumbnail.selected = Some(url.into());
thumbnail.aspect = (2, 3);
let area = Rect::new(2, 2, 80, 8);
let slot = thumbnail_size(area, thumbnail.picker.font_size(), (2, 3));
let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap();
let mut loading_width = 0;
terminal
.draw(|frame| loading_width = thumbnail.draw(frame, area))
.unwrap();
assert_eq!(loading_width, slot.width);
// The artwork need not exactly match the placeholder's 2:3 aspect.
let protocol = thumbnail
.picker
.new_resize_protocol(image::DynamicImage::new_rgb8(60, 100));
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
let (replies_tx, replies_rx) = mpsc::channel();
thumbnail.image = Some(ThumbnailImage {
id: url.into(),
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
replies: replies_rx,
complete: false,
});
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
let response = requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.resize_encode()
.unwrap();
replies_tx.send(Ok(response)).unwrap();
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
assert!(!thumbnail.is_loading());
let size = thumbnail
.image
.as_ref()
.unwrap()
.protocol
.size_for(Resize::Scale(None), slot)
.unwrap();
assert!(size.width <= slot.width && size.height <= slot.height);
let font = thumbnail.picker.font_size();
let width = u32::from(size.width) * u32::from(font.width);
let height = u32::from(size.height) * u32::from(font.height);
assert!(width.abs_diff(height * 3 / 5) <= u32::from(font.width) + u32::from(font.height));
thumbnail.cancel();
thumbnail.select_url(url);
assert!(thumbnail.fetch.is_none());
assert_eq!(thumbnail.selected.as_deref(), Some(url));
terminal
.draw(|frame| {
assert_eq!(thumbnail.draw(frame, area), loading_width);
})
.unwrap();
thumbnail.clear_cache();
assert!(thumbnail.image.is_none() && thumbnail.cached.is_empty());
}
#[test]
fn guesses_cover_formats_and_rejects_corrupt_or_oversized_input() {
assert!(decode(b"not a JPEG".to_vec()).is_err());
assert!(decode(vec![0; 2 * 1024 * 1024 + 1]).is_err());
for format in [
image::ImageFormat::Jpeg,
image::ImageFormat::Png,
image::ImageFormat::WebP,
] {
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 3)
.write_to(&mut bytes, format)
.unwrap();
let image = decode(bytes.into_inner()).unwrap();
assert_eq!((image.width(), image.height()), (2, 3));
}
}
#[test]
fn cancellation_discards_queued_decode_completion() {
let mut thumbnail = Thumbnail::new();
let (tx, rx) = mpsc::channel();
let (requests_tx, _requests_rx) = mpsc::channel();
let (_replies_tx, replies_rx) = mpsc::channel();
let protocol = thumbnail
.picker
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1));
thumbnail.selected = Some("old-video".into());
thumbnail.decoder = Some(rx);
assert!(
tx.send(Ok(ThumbnailImage {
id: "old-video".into(),
protocol: ThreadProtocol::new(requests_tx, Some(protocol)),
replies: replies_rx,
complete: false,
}))
.is_ok()
);
thumbnail.cancel();
thumbnail.selected = Some("new-video".into());
thumbnail.poll();
assert!(thumbnail.image.is_none());
assert!(thumbnail.error.is_none());
}
#[test]
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
let mut thumbnail = Thumbnail::new();
let completed = |id: String| {
let (requests, _receiver) = mpsc::channel();
let (_sender, replies) = mpsc::channel();
ThumbnailImage {
id,
protocol: ThreadProtocol::new(
requests,
Some(Picker::halfblocks().new_resize_protocol(image::DynamicImage::new_rgb8(1, 1))),
),
replies,
complete: true,
}
};
for index in 0..CACHE_SIZE {
thumbnail.image = Some(completed(format!("video{index:06}")));
thumbnail.cancel();
}
thumbnail.image = Some(completed("video000008".into()));
thumbnail.selected = Some("video000008".into());
// This is the oldest cached image: storing the outgoing image first would evict it.
thumbnail.select("video000000");
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
assert!(thumbnail.fetch.is_none());
thumbnail.select("video000001");
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
assert!(thumbnail.fetch.is_none());
thumbnail.cancel();
assert!(
!thumbnail
.cached
.iter()
.any(|image| image.id == "video000002")
);
assert!(
thumbnail
.cached
.iter()
.any(|image| image.id == "video000000")
);
assert!(
thumbnail
.cached
.iter()
.any(|image| image.id == "video000008")
);
thumbnail.clear_cache();
assert!(thumbnail.cached.is_empty());
assert!(thumbnail.image.is_none());
}
#[test]
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
use ratatui::{Terminal, backend::TestBackend, style::Color};
use std::time::Duration;
let mut thumbnail = Thumbnail::new();
let font = thumbnail.picker.font_size();
let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
48,
36,
image::Rgb([240, 40, 70]),
));
let (requests_tx, requests_rx) = mpsc::channel::<ResizeRequest>();
let (replies_tx, replies_rx) = mpsc::channel();
thumbnail.selected = Some("thumbnail".into());
thumbnail.image = Some(ThumbnailImage {
id: "thumbnail".into(),
protocol: ThreadProtocol::new(
requests_tx,
Some(thumbnail.picker.new_resize_protocol(image)),
),
replies: replies_rx,
complete: false,
});
let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap();
for area in [
Rect::new(3, 2, 80, 18),
Rect::new(7, 4, 30, 8),
Rect::new(3, 2, 9, 18),
Rect::new(7, 4, 30, 8),
] {
let mut pending_width = 0;
terminal
.draw(|frame| {
pending_width = thumbnail.draw(frame, area);
})
.unwrap();
assert!(thumbnail.is_loading());
assert!(
!terminal.backend().buffer().content.iter().any(|cell| {
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
}),
"a resize must not render stale pixels"
);
let response = requests_rx
.recv_timeout(Duration::from_secs(2))
.unwrap()
.resize_encode()
.unwrap();
replies_tx.send(Ok(response)).unwrap();
let mut occupied_width = 0;
terminal
.draw(|frame| {
occupied_width = thumbnail.draw(frame, area);
})
.unwrap();
assert!(!thumbnail.is_loading());
assert_eq!(pending_width, occupied_width);
let buffer = terminal.backend().buffer();
let pixels: Vec<_> = (0..40)
.flat_map(|y| (0..100).map(move |x| (x, y)))
.filter(|&(x, y)| {
let cell = &buffer[(x, y)];
matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..))
})
.collect();
let left = pixels.iter().map(|&(x, _)| x).min().unwrap();
let right = pixels.iter().map(|&(x, _)| x).max().unwrap() + 1;
let top = pixels.iter().map(|&(_, y)| y).min().unwrap();
let bottom = pixels.iter().map(|&(_, y)| y).max().unwrap() + 1;
assert!(left >= area.x && right <= area.right());
assert_eq!(left, area.x);
assert_eq!(occupied_width, right - left);
assert!(top >= area.y && bottom <= area.bottom());
assert!(right - left == area.width || bottom - top == area.height);
assert!((top - area.y).abs_diff(area.bottom() - bottom) <= 1);
let width = u32::from(right - left) * u32::from(font.width);
let height = u32::from(bottom - top) * u32::from(font.height);
assert!(
width.abs_diff(height * 4 / 3)
<= u32::from(font.width) + u32::from(font.height) * 4 / 3
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff