1196 lines
48 KiB
Rust
1196 lines
48 KiB
Rust
use super::*;
|
|
|
|
struct TestDirectory(PathBuf);
|
|
|
|
impl TestDirectory {
|
|
fn new() -> Self {
|
|
loop {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"tubular-config-test-{}-{}",
|
|
std::process::id(),
|
|
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
|
));
|
|
match fs::create_dir(&path) {
|
|
Ok(()) => return Self(path),
|
|
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
|
Err(error) => panic!("Cannot create test directory: {error}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDirectory {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn existing_edge_separator_handles_can_be_saved_and_reloaded() {
|
|
let channel = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
|
|
for handle in ["@hontame_", "@_creator", "@.creator-"] {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let tags = BTreeMap::from([("Immersion".to_owned(), true)]);
|
|
let saved = set_channel_tags(&path, &tags, handle, channel).unwrap();
|
|
assert_eq!(saved.channels[handle], channel);
|
|
assert_eq!(saved.categories["Immersion"], [channel]);
|
|
let reloaded = load(&path).unwrap();
|
|
assert_eq!(reloaded.channels, saved.channels);
|
|
assert_eq!(reloaded.categories, saved.categories);
|
|
let unchecked = BTreeMap::from([("Immersion".to_owned(), false)]);
|
|
set_channel_tags(
|
|
&path,
|
|
&unchecked,
|
|
&format!("https://www.youtube.com/{handle}"),
|
|
channel,
|
|
)
|
|
.unwrap();
|
|
let reloaded = load(&path).unwrap();
|
|
assert!(reloaded.categories["Immersion"].is_empty());
|
|
assert_eq!(reloaded.channels[handle], channel);
|
|
assert_eq!(reloaded.channels.len(), 1);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn key_remaps_survive_all_saves_and_section_orders() {
|
|
use crate::keys::{Action, Context};
|
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
|
|
let sections = [
|
|
("keys", key_section),
|
|
("settings", "[settings]\npinned_categories = []\n"),
|
|
(
|
|
"channels",
|
|
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
|
|
),
|
|
("categories", "[categories]\nA = ['@example']\nB = []\n"),
|
|
];
|
|
for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] {
|
|
let original = order
|
|
.iter()
|
|
.map(|index| sections[*index].1)
|
|
.collect::<String>();
|
|
fs::write(&path, original).unwrap();
|
|
let loaded = load(&path).unwrap();
|
|
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
|
|
assert_eq!(
|
|
loaded.keys.resolve(
|
|
Context::Normal,
|
|
KeyEvent::new(KeyCode::Char('x'), modifiers)
|
|
),
|
|
Some(Action::Quit),
|
|
);
|
|
}
|
|
assert_eq!(
|
|
loaded.keys.resolve(
|
|
Context::Normal,
|
|
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
|
|
),
|
|
None,
|
|
);
|
|
assert_eq!(
|
|
loaded.keys.resolve(
|
|
Context::SelectorNormal,
|
|
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
|
|
),
|
|
Some(Action::SelectorPin),
|
|
);
|
|
assert_eq!(
|
|
loaded.keys.resolve(
|
|
Context::Normal,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
|
|
),
|
|
None
|
|
);
|
|
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
|
|
let channel_saved =
|
|
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]);
|
|
let category_saved =
|
|
set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap();
|
|
let pin_saved = set_category_pinned(&path, "B", true).unwrap();
|
|
for config in [
|
|
channel_saved,
|
|
category_saved,
|
|
pin_saved,
|
|
load(&path).unwrap(),
|
|
] {
|
|
assert_eq!(
|
|
config.keys.resolve(
|
|
Context::Normal,
|
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
|
|
),
|
|
Some(Action::Quit)
|
|
);
|
|
assert_eq!(
|
|
config.keys.resolve(
|
|
Context::Normal,
|
|
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
|
|
),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
config.keys.resolve(
|
|
Context::SelectorNormal,
|
|
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
|
|
),
|
|
Some(Action::SelectorPin)
|
|
);
|
|
}
|
|
let saved = fs::read_to_string(&path).unwrap();
|
|
assert!(
|
|
saved.contains(key_section),
|
|
"key formatting changed: {saved}"
|
|
);
|
|
let document = saved.parse::<DocumentMut>().unwrap();
|
|
assert_eq!(
|
|
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
|
order
|
|
.iter()
|
|
.map(|index| sections[*index].0)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
|
|
let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()];
|
|
for invalid_keys in [
|
|
"[keys]\napp.quit = 42\n",
|
|
"[keys]\nunknown.action = 'x'\n",
|
|
"[keys]\napp.quit = 'not-a-key'\n",
|
|
"[keys]\nview.refresh = 'q'\n",
|
|
"keys = 'invalid'\n",
|
|
] {
|
|
let original = format!(
|
|
"{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n"
|
|
);
|
|
fs::write(&path, &original).unwrap();
|
|
assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
|
|
let channel_error =
|
|
set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err();
|
|
assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err();
|
|
assert_eq!(category_error.kind(), io::ErrorKind::InvalidData);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
|
|
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
let create_error = create_category(&path, "New", &expected).unwrap_err();
|
|
assert_eq!(create_error.kind(), io::ErrorKind::InvalidData);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let sections = [
|
|
(
|
|
"settings",
|
|
"[settings]\npinned_categories = ['A']\ncustom = 42\n",
|
|
),
|
|
(
|
|
"channels",
|
|
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
|
|
),
|
|
("categories", "[categories]\nA = []\nB = ['@example']\n"),
|
|
];
|
|
for order in [
|
|
[0, 1, 2],
|
|
[0, 2, 1],
|
|
[1, 0, 2],
|
|
[1, 2, 0],
|
|
[2, 0, 1],
|
|
[2, 1, 0],
|
|
] {
|
|
let original = order
|
|
.iter()
|
|
.map(|index| sections[*index].1)
|
|
.collect::<String>();
|
|
fs::write(&path, original).unwrap();
|
|
let config = load(&path).unwrap();
|
|
assert_eq!(config.pinned_categories, ["A"]);
|
|
assert_eq!(
|
|
config.categories["B"],
|
|
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
|
|
);
|
|
assert_eq!(
|
|
set_category_pinned(&path, "B", true)
|
|
.unwrap()
|
|
.pinned_categories,
|
|
["A", "B"]
|
|
);
|
|
assert_eq!(
|
|
set_category_pinned(&path, "A", false)
|
|
.unwrap()
|
|
.pinned_categories,
|
|
["B"]
|
|
);
|
|
let saved = load(&path).unwrap();
|
|
assert_eq!(saved.pinned_categories, ["B"]);
|
|
assert_eq!(saved.categories, config.categories);
|
|
assert_eq!(saved.channels, config.channels);
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
let document = contents.parse::<DocumentMut>().unwrap();
|
|
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
|
|
assert_eq!(
|
|
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
|
order
|
|
.iter()
|
|
.map(|index| sections[*index].0)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
}
|
|
for original in [
|
|
"[categories]\nA = []\n",
|
|
"[settings]\ncustom = 42\n[categories]\nA = []\n",
|
|
] {
|
|
fs::write(&path, original).unwrap();
|
|
set_category_pinned(&path, "A", true).unwrap();
|
|
assert_eq!(load(&path).unwrap().pinned_categories, ["A"]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
|
|
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
|
|
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
|
|
let tags = BTreeMap::from([("Japanese".into(), true)]);
|
|
let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
|
|
assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]);
|
|
assert_eq!(
|
|
saved.categories["Japanese"],
|
|
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
|
|
);
|
|
let unpinned = set_category_pinned(&path, "Japanese", false).unwrap();
|
|
assert_eq!(unpinned.pinned_categories, ["Chinese"]);
|
|
assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]);
|
|
let content = fs::read_to_string(&path).unwrap();
|
|
assert!(content.contains("# favorites") && content.contains("# pin note"));
|
|
assert_eq!(
|
|
content.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
|
|
Some(42)
|
|
);
|
|
assert!(set_category_pinned(&path, "Missing", true).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
|
for invalid in [
|
|
"[settings]\npinned_categories = true",
|
|
"[settings]\npinned_categories = [4]",
|
|
"[settings]\npinned_categories = ['A', ' A ']",
|
|
"settings = []",
|
|
"pinned_categories = ['A']",
|
|
] {
|
|
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
|
|
let before = fs::read(&path).unwrap();
|
|
assert!(set_category_pinned(&path, "A", true).is_err());
|
|
assert!(set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), before);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn category_creation_deduplicates_aliases_and_preserves_original_config() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let url = format!("https://www.youtube.com/channel/{id}");
|
|
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [' Source '] # favorite\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@zeta' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n'@alpha' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\n# category note\n' Source ' = [\n '@zeta', # entry note\n '@manual',\n '@manual',\n] # array note\nEmpty = []\n";
|
|
fs::write(&path, original).unwrap();
|
|
let before = load(&path).unwrap();
|
|
let created = create_category(
|
|
&path,
|
|
" Combined ",
|
|
&[
|
|
id.to_owned(),
|
|
url.clone(),
|
|
"@zeta".to_owned(),
|
|
"https://youtube.com/@alpha".to_owned(),
|
|
"@manual".to_owned(),
|
|
"https://www.youtube.com/@manual".to_owned(),
|
|
"UCabcdefghijklmnopqrstuv".to_owned(),
|
|
],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
created.categories["Combined"],
|
|
[
|
|
"https://www.youtube.com/@manual".to_owned(),
|
|
url,
|
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv".to_owned(),
|
|
]
|
|
);
|
|
assert_eq!(created.channels, before.channels);
|
|
assert_eq!(created.categories["Source"], before.categories["Source"]);
|
|
assert_eq!(created.categories["Empty"], before.categories["Empty"]);
|
|
assert_eq!(created.pinned_categories, before.pinned_categories);
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
let mut document = contents.parse::<DocumentMut>().unwrap();
|
|
assert_eq!(
|
|
document["categories"]["Combined"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|entry| entry.as_str().unwrap())
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
"@manual",
|
|
"@alpha",
|
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
|
]
|
|
);
|
|
document["categories"]
|
|
.as_table_like_mut()
|
|
.unwrap()
|
|
.remove("Combined");
|
|
assert_eq!(document.to_string(), original);
|
|
assert_eq!(load(&path).unwrap().categories, created.categories);
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_creation_uses_latest_mappings_and_supports_inline_unicode_and_empty() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let original = "categories = { Source = ['@first', '@first'], Empty = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\nchannels = { '@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' }\n";
|
|
fs::write(&path, original).unwrap();
|
|
let opened = load(&path).unwrap();
|
|
let latest = original.replace("UC4QobU6STFB0P71PMvOGN5A", "UCabcdefghijklmnopqrstuv");
|
|
fs::write(&path, &latest).unwrap();
|
|
let created = create_category(
|
|
&path,
|
|
" 日本語 🎵 ",
|
|
&["@mapped".to_owned(), "UCabcdefghijklmnopqrstuv".to_owned()],
|
|
)
|
|
.unwrap();
|
|
assert_ne!(created.channels, opened.channels);
|
|
assert_eq!(
|
|
created.categories["日本語 🎵"],
|
|
["https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"]
|
|
);
|
|
assert_eq!(created.pinned_categories, ["Source"]);
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
let document = contents.parse::<DocumentMut>().unwrap();
|
|
let before_document = latest.parse::<DocumentMut>().unwrap();
|
|
assert!(document["categories"].is_inline_table());
|
|
assert_eq!(
|
|
document["categories"]["日本語 🎵"][0].as_str(),
|
|
Some("@mapped")
|
|
);
|
|
for section in ["settings", "channels"] {
|
|
assert_eq!(
|
|
document[section].to_string(),
|
|
before_document[section].to_string()
|
|
);
|
|
}
|
|
for name in ["Source", "Empty"] {
|
|
assert_eq!(
|
|
document["categories"][name].to_string(),
|
|
before_document["categories"][name].to_string()
|
|
);
|
|
}
|
|
assert!(contents.contains("# categories note"));
|
|
let empty = create_category(&path, "New empty", &[]).unwrap();
|
|
assert!(empty.categories["New empty"].is_empty());
|
|
assert_eq!(empty.pinned_categories, ["Source"]);
|
|
let missing = directory.0.join("nested/config.toml");
|
|
let empty = create_category(&missing, "Empty", &[]).unwrap();
|
|
assert!(empty.categories["Empty"].is_empty());
|
|
assert!(empty.channels.is_empty());
|
|
assert!(empty.pinned_categories.is_empty());
|
|
assert_eq!(load(&missing).unwrap().categories, empty.categories);
|
|
}
|
|
|
|
#[test]
|
|
fn category_creation_rejects_invalid_names_channels_and_configs_without_writes() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let original = "[categories]\n' Source ' = ['@manual']\nEmpty = []\n";
|
|
fs::write(&path, original).unwrap();
|
|
for name in [
|
|
"",
|
|
" ",
|
|
"Bad\nName",
|
|
"Bad\u{7f}Name",
|
|
"Source",
|
|
" Source ",
|
|
"Empty",
|
|
] {
|
|
assert!(create_category(&path, name, &[]).is_err(), "{name:?}");
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
}
|
|
// Even an identical membership snapshot must not overwrite a category.
|
|
assert!(create_category(&path, "Source", &["@manual".to_owned()]).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
for channel in [
|
|
"",
|
|
"bad",
|
|
"@bad handle",
|
|
"@manual\n",
|
|
"https://example.com/@manual",
|
|
"https://www.youtube.com/@manual/videos",
|
|
] {
|
|
assert!(
|
|
create_category(&path, "New", &["@valid".to_owned(), channel.to_owned()]).is_err(),
|
|
"{channel:?}"
|
|
);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
}
|
|
for malformed in [
|
|
"[categories\n",
|
|
"categories = 3\n",
|
|
"[categories]\nSource = [7]\n",
|
|
"[categories]\nSource = []\n' Source ' = []\n",
|
|
"[channels]\n'@mapped' = '@other'\n",
|
|
"[settings]\npinned_categories = [7]\n",
|
|
"[keys.app]\nunknown = ['q']\n",
|
|
] {
|
|
fs::write(&path, malformed).unwrap();
|
|
assert!(create_category(&path, "New", &[]).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), malformed.as_bytes());
|
|
}
|
|
let missing = directory.0.join("missing/config.toml");
|
|
assert!(create_category(&missing, "Bad\nName", &[]).is_err());
|
|
assert!(create_category(&missing, "New", &["@bad handle".to_owned()]).is_err());
|
|
assert!(!missing.parent().unwrap().exists());
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_rename_preserves_memberships_pins_comments_and_section_order() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n";
|
|
fs::write(&path, original).unwrap();
|
|
let before = load(&path).unwrap();
|
|
let before_document = original.parse::<DocumentMut>().unwrap();
|
|
let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap();
|
|
assert!(!renamed.categories.contains_key("Source"));
|
|
assert_eq!(
|
|
renamed.categories["Renamed group"],
|
|
before.categories["Source"]
|
|
);
|
|
assert_eq!(renamed.categories["Keep"], before.categories["Keep"]);
|
|
assert_eq!(renamed.channels, before.channels);
|
|
assert_eq!(renamed.subscriptions(), before.subscriptions());
|
|
assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]);
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
let document = contents.parse::<DocumentMut>().unwrap();
|
|
assert_eq!(
|
|
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
|
|
["other", "settings", "keys", "channels", "categories"]
|
|
);
|
|
assert_eq!(
|
|
document["categories"]
|
|
.as_table_like()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|(name, _)| name)
|
|
.collect::<Vec<_>>(),
|
|
["Before", "Renamed group", "Keep"]
|
|
);
|
|
assert_eq!(
|
|
document["categories"]["Renamed group"].to_string(),
|
|
before_document["categories"][" Source "].to_string()
|
|
);
|
|
for section in ["other", "keys", "channels"] {
|
|
assert_eq!(
|
|
document[section].to_string(),
|
|
before_document[section].to_string()
|
|
);
|
|
}
|
|
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
|
|
for comment in [
|
|
"# manual preferences",
|
|
"# favorite",
|
|
"# pin note",
|
|
"# category note",
|
|
"# entry note",
|
|
"# array note",
|
|
"# key note",
|
|
"# mapping note",
|
|
] {
|
|
assert!(contents.contains(comment), "lost {comment}");
|
|
}
|
|
assert_eq!(load(&path).unwrap().categories, renamed.categories);
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_rename_preserves_inline_tables_and_reads_latest_memberships() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n";
|
|
fs::write(&path, original).unwrap();
|
|
let opened = load(&path).unwrap();
|
|
let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'");
|
|
fs::write(&path, &latest).unwrap();
|
|
let renamed = rename_category(&path, "Source", "Renamed").unwrap();
|
|
assert_eq!(opened.categories["Source"].len(), 1);
|
|
assert_eq!(
|
|
renamed.categories["Renamed"],
|
|
[
|
|
"https://www.youtube.com/@first",
|
|
"https://www.youtube.com/@late",
|
|
]
|
|
);
|
|
assert!(!renamed.categories.contains_key("Source"));
|
|
assert_eq!(renamed.pinned_categories, ["Renamed"]);
|
|
let contents = fs::read_to_string(&path).unwrap();
|
|
assert_eq!(
|
|
contents,
|
|
latest
|
|
.replace("Source =", "Renamed =")
|
|
.replace("'Source'", "\"Renamed\"")
|
|
);
|
|
let document = contents.parse::<DocumentMut>().unwrap();
|
|
assert!(document["categories"].is_inline_table());
|
|
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n";
|
|
fs::write(&path, original).unwrap();
|
|
for (old, new) in [
|
|
("Source", ""),
|
|
("Source", " \t "),
|
|
("Source", "Bad\nName"),
|
|
("", "New"),
|
|
("Missing", "New"),
|
|
("Missing", "Missing"),
|
|
("Source", "Keep"),
|
|
("Source", " Keep "),
|
|
] {
|
|
assert!(
|
|
rename_category(&path, old, new).is_err(),
|
|
"{old:?} -> {new:?}"
|
|
);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
}
|
|
let unchanged = rename_category(&path, " Source ", "Source").unwrap();
|
|
assert_eq!(unchanged.categories, load(&path).unwrap().categories);
|
|
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
|
for invalid in [
|
|
"[settings]\npinned_categories = [7]\n[categories]\nSource = []\n",
|
|
"[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n",
|
|
"[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n",
|
|
"[categories]\nSource = [7]\n",
|
|
"[categories]\nSource = []\n' Source ' = []\n",
|
|
] {
|
|
fs::write(&path, invalid).unwrap();
|
|
assert!(rename_category(&path, "Source", "New").is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes());
|
|
}
|
|
let missing = directory.0.join("missing/config.toml");
|
|
assert!(rename_category(&missing, "Source", "New").is_err());
|
|
assert!(!missing.parent().unwrap().exists());
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let first = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let second = "UCabcdefghijklmnopqrstuv";
|
|
let direct = "UC0123456789012345678901";
|
|
let first_url = format!("https://www.youtube.com/channel/{first}");
|
|
let second_url = format!("https://www.youtube.com/channel/{second}");
|
|
let direct_url = format!("https://www.youtube.com/channel/{direct}");
|
|
let manual_url = "https://www.youtube.com/@manual".to_owned();
|
|
let original = format!(
|
|
"# manual preferences\n[settings]\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
|
|
);
|
|
fs::write(&path, &original).unwrap();
|
|
let opened = load(&path).unwrap();
|
|
let before = original.parse::<DocumentMut>().unwrap();
|
|
// This category was not present in the dialog's tags.
|
|
fs::write(
|
|
&path,
|
|
format!("{original}Late = ['@alias', '@unrelated'] # late note\n"),
|
|
)
|
|
.unwrap();
|
|
let tags = BTreeMap::from([
|
|
("Source".to_owned(), false),
|
|
("Keep".to_owned(), true),
|
|
("Drop".to_owned(), false),
|
|
(" New ".to_owned(), true),
|
|
]);
|
|
let saved = set_category_tags(&path, " Source ", &opened.categories["Source"], &tags).unwrap();
|
|
assert!(saved.categories["Source"].is_empty());
|
|
for name in ["Drop", "Late", "Untouched"] {
|
|
assert_eq!(
|
|
saved.categories[name],
|
|
["https://www.youtube.com/@unrelated"]
|
|
);
|
|
}
|
|
assert_eq!(
|
|
saved.categories["Keep"],
|
|
[
|
|
first_url.clone(),
|
|
"https://www.youtube.com/@unrelated".to_owned(),
|
|
manual_url.clone(),
|
|
direct_url.clone(),
|
|
second_url.clone(),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
saved.categories["New"],
|
|
[manual_url, direct_url, first_url, second_url]
|
|
);
|
|
assert_eq!(saved.channels, opened.channels);
|
|
assert_eq!(saved.pinned_categories, ["Source"]);
|
|
let content = fs::read_to_string(&path).unwrap();
|
|
let document = content.parse::<DocumentMut>().unwrap();
|
|
assert_eq!(
|
|
document["channels"].to_string(),
|
|
before["channels"].to_string()
|
|
);
|
|
assert_eq!(
|
|
document["settings"]["pinned_categories"].to_string(),
|
|
before["settings"]["pinned_categories"].to_string()
|
|
);
|
|
assert_eq!(document["other"]["value"].as_integer(), Some(42));
|
|
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
|
|
let new = document["categories"]["New"].as_array().unwrap();
|
|
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
|
|
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
|
|
let keep = document["categories"]["Keep"].as_array().unwrap();
|
|
assert_eq!(keep.get(0).unwrap().as_str(), Some("@alias"));
|
|
assert_eq!(keep.get(1).unwrap().as_str(), Some("@unrelated"));
|
|
for comment in [
|
|
"# manual preferences",
|
|
"# pin note",
|
|
"# alias mapping",
|
|
"# source channel",
|
|
"# direct channel",
|
|
"# source note",
|
|
"# keep note",
|
|
"# removed channel note",
|
|
"# unrelated note",
|
|
"# late note",
|
|
] {
|
|
assert!(content.contains(comment), "lost {comment}");
|
|
}
|
|
let reloaded = load(&path).unwrap();
|
|
assert_eq!(reloaded.categories, saved.categories);
|
|
assert_eq!(reloaded.subscriptions(), saved.subscriptions());
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn category_tags_compare_identity_sets_not_source_order_or_alias_spelling() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let url = format!("https://www.youtube.com/channel/{id}");
|
|
let registry = format!("[channels]\n'@old' = '{id}'\n'@alias' = '{url}'\n");
|
|
fs::write(
|
|
&path,
|
|
format!("{registry}[categories]\nSource = ['@old', '@manual']\n"),
|
|
)
|
|
.unwrap();
|
|
let expected = load(&path).unwrap().categories.remove("Source").unwrap();
|
|
fs::write(
|
|
&path,
|
|
format!(
|
|
"{registry}[categories]\nSource = ['https://youtube.com/@manual', '{url}', '@alias'] # alias note\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
let tags = BTreeMap::from([("Source".to_owned(), true), ("New".to_owned(), true)]);
|
|
let saved = set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
|
assert_eq!(
|
|
saved.categories["Source"],
|
|
["https://www.youtube.com/@manual".to_owned(), url.clone()]
|
|
);
|
|
assert_eq!(
|
|
saved.categories["New"],
|
|
["https://www.youtube.com/@manual".to_owned(), url]
|
|
);
|
|
let content = fs::read_to_string(&path).unwrap();
|
|
let document = content.parse::<DocumentMut>().unwrap();
|
|
let source = document["categories"]["Source"].as_array().unwrap();
|
|
assert_eq!(source.len(), 3);
|
|
assert_eq!(source.get(2).unwrap().as_str(), Some("@alias"));
|
|
let new = document["categories"]["New"].as_array().unwrap();
|
|
assert_eq!(new.get(1).unwrap().as_str(), Some("@alias"));
|
|
assert!(content.contains("# alias note"));
|
|
set_category_tags(&path, "Source", &expected, &tags).unwrap();
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
|
}
|
|
|
|
#[test]
|
|
fn category_tags_reject_stale_sources_invalid_files_and_tags_without_writing() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let tags = BTreeMap::from([("New".to_owned(), true)]);
|
|
let expected = vec!["https://www.youtube.com/@manual".to_owned()];
|
|
for contents in [
|
|
"[categories]\nOther = ['@manual']\n",
|
|
"[categories]\nSource = []\n",
|
|
"[categories]\nSource = ['@different']\n",
|
|
"[categories]\nSource = ['@manual', '@different']\n",
|
|
"[channels]\n'@manual' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nSource = ['@manual']\n",
|
|
"[categories\n",
|
|
"categories = true\n",
|
|
"[categories]\nSource = ['@manual']\nInvalid = [7]\n",
|
|
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
|
|
"channels = true\n[categories]\nSource = ['@manual']\n",
|
|
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
|
|
"[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
|
|
] {
|
|
fs::write(&path, contents).unwrap();
|
|
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), contents);
|
|
assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1);
|
|
}
|
|
fs::write(&path, [0xff]).unwrap();
|
|
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
|
|
assert_eq!(fs::read(&path).unwrap(), [0xff]);
|
|
let valid = "[categories]\nSource = ['@manual']\n";
|
|
fs::write(&path, valid).unwrap();
|
|
for invalid_tags in [
|
|
BTreeMap::from([(" ".to_owned(), true)]),
|
|
BTreeMap::from([("bad\nname".to_owned(), true)]),
|
|
BTreeMap::from([("New".to_owned(), true), (" New ".to_owned(), false)]),
|
|
] {
|
|
assert!(set_category_tags(&path, "Source", &expected, &invalid_tags).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
|
}
|
|
assert!(set_category_tags(&path, "bad\nname", &expected, &tags).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), valid);
|
|
let missing = directory.0.join("missing/config.toml");
|
|
assert!(set_category_tags(&missing, "Source", &expected, &tags).is_err());
|
|
assert!(!missing.parent().unwrap().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn category_tags_only_clear_all_tags_when_every_target_remains_registered() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let registry = format!(
|
|
"[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
|
|
);
|
|
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
|
|
let original = format!("{registry}Source = [{source}]\n");
|
|
fs::write(&path, &original).unwrap();
|
|
let opened = load(&path).unwrap();
|
|
for tags in [
|
|
BTreeMap::new(),
|
|
BTreeMap::from([("Source".to_owned(), false)]),
|
|
] {
|
|
let error = set_category_tags(&path, "Source", &opened.categories["Source"], &tags)
|
|
.unwrap_err();
|
|
assert!(error.to_string().contains("not registered in [channels]"));
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), original);
|
|
}
|
|
}
|
|
let original = format!(
|
|
"{registry}Source = ['@mapped', '{id}'] # source note\nOther = ['https://youtube.com/@mapped', '@unrelated']\n"
|
|
);
|
|
fs::write(&path, &original).unwrap();
|
|
let opened = load(&path).unwrap();
|
|
let saved = set_category_tags(
|
|
&path,
|
|
"Source",
|
|
&opened.categories["Source"],
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
assert!(saved.categories["Source"].is_empty());
|
|
assert_eq!(
|
|
saved.categories["Other"],
|
|
["https://www.youtube.com/@unrelated"]
|
|
);
|
|
assert_eq!(saved.channels, opened.channels);
|
|
assert_eq!(saved.pinned_categories, ["Source"]);
|
|
assert_eq!(
|
|
saved.subscriptions(),
|
|
[
|
|
"https://www.youtube.com/@unrelated",
|
|
"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A",
|
|
]
|
|
);
|
|
assert!(fs::read_to_string(&path).unwrap().contains("# source note"));
|
|
}
|
|
|
|
#[test]
|
|
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("nested/config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
assert!(load(&path).unwrap().categories.is_empty());
|
|
assert!(!path.parent().unwrap().exists());
|
|
let tags = BTreeMap::from([(" Music ".to_owned(), true)]);
|
|
let config = set_channel_tags(&path, &tags, "@example", id).unwrap();
|
|
assert_eq!(
|
|
config.channels["@example"],
|
|
format!("https://www.youtube.com/channel/{id}")
|
|
);
|
|
assert_eq!(
|
|
config.categories["Music"],
|
|
[format!("https://www.youtube.com/channel/{id}")]
|
|
);
|
|
|
|
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
|
|
fs::write(&path, manual).unwrap();
|
|
let tags = BTreeMap::from([("Music".to_owned(), true)]);
|
|
let config = set_channel_tags(&path, &tags, "https://youtube.com/@example", id).unwrap();
|
|
assert_eq!(config.categories["Music"].len(), 1);
|
|
assert!(config.categories["Empty"].is_empty());
|
|
let id = "UCabcdefghijklmnopqrstuv";
|
|
let config = set_channel_tags(&path, &tags, "@another", id).unwrap();
|
|
assert_eq!(
|
|
config.categories["Music"][1],
|
|
format!("https://www.youtube.com/channel/{id}")
|
|
);
|
|
let saved = fs::read_to_string(&path).unwrap();
|
|
for comment in [
|
|
"# manual preferences",
|
|
"# keep me",
|
|
"# favorites",
|
|
"# channel note",
|
|
] {
|
|
assert!(saved.contains(comment));
|
|
}
|
|
assert_eq!(
|
|
saved.parse::<DocumentMut>().unwrap()["other"]["value"].as_integer(),
|
|
Some(42)
|
|
);
|
|
|
|
for malformed in [
|
|
"[categories\n",
|
|
"categories = 3\n",
|
|
"[categories]\nMusic = [3]\n",
|
|
"[categories]\nMusic = ['https://example.com/@example']\n",
|
|
"[categories]\nMusic = []\n' Music ' = []\n",
|
|
"channels = 3\n",
|
|
"[channels]\n'@example' = 3\n",
|
|
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
|
|
"[channels]\n'@example' = '@another'\n",
|
|
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
|
"[channels]\n'@example' = 'UCabcdefghijklmnopqrstuv'\n'https://youtube.com/@example' = 'UCabcdefghijklmnopqrstuv'\n",
|
|
] {
|
|
fs::write(&path, malformed).unwrap();
|
|
assert!(load(&path).is_err());
|
|
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
|
}
|
|
fs::write(&path, manual).unwrap();
|
|
for tags in [
|
|
BTreeMap::from([(" ".to_owned(), true)]),
|
|
BTreeMap::from([("bad\nname".to_owned(), false)]),
|
|
BTreeMap::from([("Music".to_owned(), true), (" Music ".to_owned(), false)]),
|
|
] {
|
|
assert!(set_channel_tags(&path, &tags, "@example", id).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
}
|
|
assert!(set_channel_tags(&path, &tags, "https://www.youtube.com/@example/videos", id).is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
|
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let url = format!("https://www.youtube.com/channel/{id}");
|
|
let manual = format!(
|
|
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
|
|
);
|
|
fs::write(&path, manual).unwrap();
|
|
let config = load(&path).unwrap();
|
|
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
|
assert_eq!(
|
|
config.categories["Direct"],
|
|
["https://www.youtube.com/@RustVideos"]
|
|
);
|
|
let tags = BTreeMap::from([("Japanese".to_owned(), true)]);
|
|
let config = set_channel_tags(&path, &tags, "@jawed", id).unwrap();
|
|
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
|
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
|
let saved = fs::read_to_string(&path).unwrap();
|
|
let document = saved.parse::<DocumentMut>().unwrap();
|
|
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
|
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
|
|
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
|
assert!(saved.contains("# keep this entry comment"));
|
|
set_channel_tags(&path, &tags, "@jawed", id).unwrap();
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
|
assert!(set_channel_tags(&path, &tags, "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
|
|
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
|
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_tags_remove_stable_aliases_without_touching_other_channels() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let url = format!("https://www.youtube.com/channel/{id}");
|
|
let other_id = "UCabcdefghijklmnopqrstuv";
|
|
let other_url = format!("https://www.youtube.com/channel/{other_id}");
|
|
let manual = format!(
|
|
"# subscriptions\n[channels]\n'@old' = '{url}' # old alias\n'@other' = '{other_url}' # other mapping\n[categories]\n' First ' = [\n '@other', # unrelated creator\n '{id}',\n '@old',\n 'https://youtube.com/@old',\n '{url}',\n] # first tag\nSecond = ['@other']\nOmitted = ['@old'] # untouched tag\n"
|
|
);
|
|
fs::write(&path, &manual).unwrap();
|
|
let tags = BTreeMap::from([
|
|
("First".to_owned(), true),
|
|
("Second".to_owned(), true),
|
|
("New empty".to_owned(), false),
|
|
]);
|
|
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
assert_eq!(config.categories["First"], [other_url.clone(), url.clone()]);
|
|
assert_eq!(
|
|
config.categories["Second"],
|
|
[other_url.clone(), url.clone()]
|
|
);
|
|
assert!(config.categories["New empty"].is_empty());
|
|
let saved = fs::read_to_string(&path).unwrap();
|
|
let document = saved.parse::<DocumentMut>().unwrap();
|
|
let entries = document["categories"][" First "].as_array().unwrap();
|
|
assert_eq!(entries.len(), 5);
|
|
assert!(
|
|
entries
|
|
.iter()
|
|
.skip(1)
|
|
.all(|entry| entry.as_str() == Some("@current"))
|
|
);
|
|
assert_eq!(
|
|
document["categories"]["Omitted"]
|
|
.as_array()
|
|
.unwrap()
|
|
.get(0)
|
|
.unwrap()
|
|
.as_str(),
|
|
Some("@old")
|
|
);
|
|
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
|
assert_eq!(
|
|
document["channels"]["@other"].as_str(),
|
|
Some(other_url.as_str())
|
|
);
|
|
|
|
// Restore manually written aliases so deselection exercises every accepted spelling.
|
|
fs::write(&path, &manual).unwrap();
|
|
let tags = BTreeMap::from([
|
|
("First".to_owned(), false),
|
|
("Second".to_owned(), true),
|
|
("New empty".to_owned(), false),
|
|
]);
|
|
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
|
|
assert_eq!(
|
|
config.categories["Second"],
|
|
[other_url.clone(), url.clone()]
|
|
);
|
|
assert_eq!(config.categories["Omitted"], std::slice::from_ref(&url));
|
|
assert!(config.categories["New empty"].is_empty());
|
|
let saved = fs::read_to_string(&path).unwrap();
|
|
for comment in [
|
|
"# subscriptions",
|
|
"# old alias",
|
|
"# other mapping",
|
|
"# unrelated creator",
|
|
"# first tag",
|
|
"# untouched tag",
|
|
] {
|
|
assert!(saved.contains(comment), "lost {comment}");
|
|
}
|
|
let tags = BTreeMap::from([
|
|
("First".to_owned(), false),
|
|
("Second".to_owned(), false),
|
|
("Omitted".to_owned(), false),
|
|
]);
|
|
set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
let config = load(&path).unwrap();
|
|
assert_eq!(config.categories["First"], std::slice::from_ref(&other_url));
|
|
assert_eq!(config.categories["Second"], [other_url]);
|
|
assert!(config.categories["Omitted"].is_empty());
|
|
assert!(config.categories["New empty"].is_empty());
|
|
assert!(
|
|
fs::read_to_string(&path)
|
|
.unwrap()
|
|
.contains("# unrelated creator")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_channels_use_readable_aliases_and_include_every_subscription_once() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let mapped_id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let untagged_id = "UCabcdefghijklmnopqrstuv";
|
|
let legacy_id = "UCzyxwvutsrqponmlkjihgfe";
|
|
let mapped = format!("https://www.youtube.com/channel/{mapped_id}");
|
|
let untagged = format!("https://www.youtube.com/channel/{untagged_id}");
|
|
let legacy = format!("https://www.youtube.com/channel/{legacy_id}");
|
|
fs::write(
|
|
&path,
|
|
format!(
|
|
"[channels]\n'@zAlias' = '{mapped_id}'\n'https://youtube.com/@aAlias' = '{mapped}'\n'@untagged' = '{untagged_id}'\n[categories]\nFirst = ['@zAlias', '{mapped_id}', '@manual', '{legacy_id}']\nSecond = ['@aAlias', 'https://youtube.com/@manual', '{legacy}']\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
let config = load(&path).unwrap();
|
|
let expected = vec![
|
|
("@aAlias".to_owned(), mapped.clone()),
|
|
("@untagged".to_owned(), untagged.clone()),
|
|
(
|
|
"https://www.youtube.com/@manual".to_owned(),
|
|
"https://www.youtube.com/@manual".to_owned(),
|
|
),
|
|
(legacy.clone(), legacy.clone()),
|
|
];
|
|
assert_eq!(config.saved_channels(), expected);
|
|
assert_eq!(load(&path).unwrap().saved_channels(), expected);
|
|
let mut identities: Vec<_> = config
|
|
.saved_channels()
|
|
.into_iter()
|
|
.map(|(_, identity)| identity)
|
|
.collect();
|
|
identities.sort_unstable();
|
|
assert_eq!(identities, config.subscriptions());
|
|
assert!(
|
|
config
|
|
.categories
|
|
.values()
|
|
.flatten()
|
|
.all(|id| id != &untagged)
|
|
);
|
|
assert_eq!(config.channels["@zAlias"], mapped);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_channels_support_empty_and_registry_only_configs() {
|
|
assert!(Config::default().saved_channels().is_empty());
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
|
|
fs::write(
|
|
&path,
|
|
format!("[channels]\n'@zAlias' = '{identity}'\n'@aAlias' = '{identity}'\n"),
|
|
)
|
|
.unwrap();
|
|
let config = load(&path).unwrap();
|
|
assert!(config.categories.is_empty());
|
|
assert_eq!(
|
|
config.saved_channels(),
|
|
vec![("@aAlias".to_owned(), identity.to_owned())]
|
|
);
|
|
assert_eq!(config.subscriptions(), [identity]);
|
|
}
|
|
|
|
#[test]
|
|
fn subscriptions_include_untagged_registry_and_manual_channels_after_tag_removal() {
|
|
let directory = TestDirectory::new();
|
|
let path = directory.0.join("config.toml");
|
|
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
|
let url = format!("https://www.youtube.com/channel/{id}");
|
|
let other_id = "UCabcdefghijklmnopqrstuv";
|
|
let other_url = format!("https://www.youtube.com/channel/{other_id}");
|
|
let registry = format!("[channels]\n'https://youtube.com/@old' = '{id}'\n'@alias' = '{url}'\n");
|
|
fs::write(&path, ®istry).unwrap();
|
|
let config = load(&path).unwrap();
|
|
assert!(config.categories.is_empty());
|
|
assert_eq!(config.channels["@old"], url);
|
|
assert_eq!(config.subscriptions(), std::slice::from_ref(&url));
|
|
|
|
let config = set_channel_tags(&path, &BTreeMap::new(), "@untagged", other_id).unwrap();
|
|
assert!(config.categories.is_empty());
|
|
assert_eq!(config.subscriptions(), [url.clone(), other_url.clone()]);
|
|
assert_eq!(load(&path).unwrap().subscriptions(), config.subscriptions());
|
|
|
|
fs::write(
|
|
&path,
|
|
format!(
|
|
"{registry}[categories]\nTagged = ['@old', '@alias', '{url}', '{id}']\nManual = ['@RustVideos', 'https://youtube.com/@RustVideos', '{other_id}', '{other_url}']\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
let expected = vec![
|
|
"https://www.youtube.com/@RustVideos".to_owned(),
|
|
url.clone(),
|
|
other_url,
|
|
];
|
|
let config = load(&path).unwrap();
|
|
assert_eq!(config.categories["Tagged"], std::slice::from_ref(&url));
|
|
assert_eq!(config.subscriptions(), expected);
|
|
let tags = BTreeMap::from([("Tagged".to_owned(), false)]);
|
|
let config = set_channel_tags(&path, &tags, "@current", id).unwrap();
|
|
assert!(config.categories["Tagged"].is_empty());
|
|
assert_eq!(config.subscriptions(), expected);
|
|
assert_eq!(load(&path).unwrap().subscriptions(), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn paths_and_channel_addresses_are_strict() {
|
|
let home = Some(OsString::from("/home/test"));
|
|
let fallback = PathBuf::from("/home/test/.config/tubular/config.toml");
|
|
for xdg in [
|
|
None,
|
|
Some(OsString::new()),
|
|
Some(OsString::from("relative")),
|
|
] {
|
|
assert_eq!(resolve_path(xdg, home.clone()).unwrap(), fallback);
|
|
}
|
|
assert_eq!(
|
|
resolve_path(Some("/config".into()), None).unwrap(),
|
|
PathBuf::from("/config/tubular/config.toml")
|
|
);
|
|
assert!(resolve_path(None, None).is_err());
|
|
assert!(resolve_path(None, Some(OsString::new())).is_err());
|
|
for channel in [
|
|
"",
|
|
"@",
|
|
"@bad/name",
|
|
"@bad?query",
|
|
"@bad#fragment",
|
|
"@bad\n",
|
|
"https://www.youtube.com.evil/@example",
|
|
"http://www.youtube.com/@example",
|
|
"https://www.youtube.com/channel/UCshort",
|
|
"channel/UCshort",
|
|
"https://www.youtube.com/@example/videos",
|
|
"https://www.youtube.com/@example?x=1",
|
|
"https://www.youtube.com/@example/../other",
|
|
"https://www.youtube.com/@example//",
|
|
] {
|
|
assert!(normalize_channel(channel).is_err(), "accepted {channel:?}");
|
|
}
|
|
assert_eq!(
|
|
normalize_channel("@example").unwrap(),
|
|
"https://www.youtube.com/@example"
|
|
);
|
|
assert_eq!(
|
|
normalize_channel("https://youtube.com/channel/UCabcdefghijklmnopqrstuv").unwrap(),
|
|
"https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv"
|
|
);
|
|
assert!(category_name(" \t ").is_err());
|
|
}
|