aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--crates/yt/Cargo.toml2
-rw-r--r--crates/yt/tests/_testenv/init.rs137
-rw-r--r--crates/yt/tests/_testenv/mod.rs25
-rw-r--r--crates/yt/tests/_testenv/run.rs168
-rw-r--r--crates/yt/tests/_testenv/util.rs361
-rw-r--r--crates/yt/tests/select/base.rs40
-rw-r--r--crates/yt/tests/select/file.rs21
-rw-r--r--crates/yt/tests/select/mod.rs15
-rw-r--r--crates/yt/tests/select/options.rs41
-rw-r--r--crates/yt/tests/subscriptions/import_export/golden.txt2
-rw-r--r--crates/yt/tests/subscriptions/import_export/mod.rs24
-rw-r--r--crates/yt/tests/subscriptions/mod.rs2
-rw-r--r--crates/yt/tests/subscriptions/naming_subscriptions/golden.txt2
-rw-r--r--crates/yt/tests/subscriptions/naming_subscriptions/mod.rs23
-rw-r--r--crates/yt/tests/tests.rs12
-rw-r--r--crates/yt/tests/videos/downloading.rs42
-rw-r--r--crates/yt/tests/videos/mod.rs1
-rw-r--r--crates/yt/tests/watch/focus_switch.rs42
-rw-r--r--crates/yt/tests/watch/mod.rs121
19 files changed, 1079 insertions, 2 deletions
diff --git a/crates/yt/Cargo.toml b/crates/yt/Cargo.toml
index 0a9227f..ef6ec07 100644
--- a/crates/yt/Cargo.toml
+++ b/crates/yt/Cargo.toml
@@ -56,8 +56,6 @@ name = "yt"
doc = false
path = "src/main.rs"
-[dev-dependencies]
-
[lints]
workspace = true
diff --git a/crates/yt/tests/_testenv/init.rs b/crates/yt/tests/_testenv/init.rs
new file mode 100644
index 0000000..a584dc9
--- /dev/null
+++ b/crates/yt/tests/_testenv/init.rs
@@ -0,0 +1,137 @@
+use std::{
+ env,
+ fs::{self, OpenOptions},
+ io::{self, Write},
+ path::PathBuf,
+ process,
+};
+
+use libc::SIGINT;
+
+use crate::{_testenv::Paths, testenv::TestEnv};
+
+fn target_dir() -> PathBuf {
+ // Tests exe is in target/debug/deps, the *yt* exe is in target/debug
+ env::current_exe()
+ .expect("./target/debug/deps/yt-*")
+ .parent()
+ .expect("./target/debug/deps")
+ .parent()
+ .expect("./target/debug")
+ .parent()
+ .expect("./target")
+ .to_path_buf()
+}
+
+fn test_dir(name: &'static str) -> PathBuf {
+ target_dir().join("tests").join(name)
+}
+
+fn prepare_files_and_dirs(name: &'static str) -> io::Result<Paths> {
+ let test_dir = test_dir(name);
+
+ fs::create_dir_all(&test_dir)?;
+
+ let db_path = test_dir.join("database.sqlite");
+ let last_selection_path = test_dir.join("last_selection");
+ let config_path = test_dir.join("config.toml");
+ let download_dir = test_dir.join("download");
+
+ {
+ // Remove all files, that are not the download dir.
+ for entry in fs::read_dir(test_dir).expect("Works") {
+ let entry = entry.unwrap();
+ let entry_ft = entry.file_type().unwrap();
+
+ if entry_ft.is_dir() && entry.file_name() == "download" {
+ continue;
+ }
+
+ if entry_ft.is_dir() {
+ fs::remove_dir_all(entry.path()).unwrap();
+ } else {
+ fs::remove_file(entry.path()).unwrap();
+ }
+ }
+ }
+
+ fs::create_dir_all(&download_dir)?;
+
+ {
+ let mut config_file = OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .create(true)
+ .open(&config_path)?;
+
+ writeln!(
+ config_file,
+ r#"[paths]
+download_dir = "{}"
+mpv_config_path = "/dev/null"
+mpv_input_path = "/dev/null"
+database_path = "{}"
+last_selection_path = "{}"
+
+[download]
+max_cache_size = "100GiB"
+
+[update]
+max_backlog = 1
+"#,
+ download_dir.display(),
+ db_path.display(),
+ last_selection_path.display(),
+ )?;
+ config_file.flush()?;
+ }
+
+ Ok(Paths {
+ db: db_path,
+ last_selection: last_selection_path,
+ config: config_path,
+ download_dir,
+ })
+}
+
+/// Find the *yt* executable.
+fn find_yt_exe() -> PathBuf {
+ let target = target_dir().join("debug");
+
+ let exe_name = if cfg!(windows) { "yt.exe" } else { "yt" };
+
+ target.join(exe_name)
+}
+
+impl TestEnv {
+ pub(crate) fn new(name: &'static str) -> TestEnv {
+ let yt_exe = find_yt_exe();
+ let test_dir = test_dir(name);
+
+ let paths = prepare_files_and_dirs(name).expect("config dir");
+
+ TestEnv {
+ name,
+ yt_exe,
+ test_dir,
+ paths,
+ spawned_childs: vec![],
+ }
+ }
+}
+
+impl Drop for TestEnv {
+ fn drop(&mut self) {
+ for _ in &self.spawned_childs {
+ // unsafe {
+ // TODO(@bpeetz): Use the PidFd feature. <2025-07-07>
+ // I would rather have zombie processes instead of killing random pid (they could have
+ // been recycled since we spawned the original command.)
+ // assert_eq!(
+ // libc::kill(i32::try_from(*pid).expect("Should be small enough"), SIGINT),
+ // 0
+ // );
+ // };
+ }
+ }
+}
diff --git a/crates/yt/tests/_testenv/mod.rs b/crates/yt/tests/_testenv/mod.rs
new file mode 100644
index 0000000..fe3f3b8
--- /dev/null
+++ b/crates/yt/tests/_testenv/mod.rs
@@ -0,0 +1,25 @@
+//! This code was taken from *fd* at 30-06-2025.
+
+use std::path::PathBuf;
+
+mod init;
+mod run;
+pub(crate) mod util;
+
+/// Environment for the integration tests.
+pub(crate) struct TestEnv {
+ pub(crate) name: &'static str,
+ pub(crate) yt_exe: PathBuf,
+ pub(crate) test_dir: PathBuf,
+
+ pub(crate) paths: Paths,
+
+ pub(crate) spawned_childs: Vec<u32>,
+}
+
+pub(crate) struct Paths {
+ pub(crate) db: PathBuf,
+ pub(crate) last_selection: PathBuf,
+ pub(crate) config: PathBuf,
+ pub(crate) download_dir: PathBuf,
+}
diff --git a/crates/yt/tests/_testenv/run.rs b/crates/yt/tests/_testenv/run.rs
new file mode 100644
index 0000000..954e112
--- /dev/null
+++ b/crates/yt/tests/_testenv/run.rs
@@ -0,0 +1,168 @@
+use std::{
+ collections::HashMap,
+ process::{self, Stdio},
+};
+
+use owo_colors::OwoColorize;
+
+use crate::testenv::TestEnv;
+
+/// Format an error message for when *yt* did not exit successfully.
+fn format_exit_error(args: &[&str], output: &process::Output) -> String {
+ format!(
+ "`yt {}` did not exit successfully.\nstdout:\n---\n{}---\nstderr:\n---\n{}---",
+ args.join(" "),
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ )
+}
+
+/// Format an error message for when the output of *yt* did not match the expected output.
+fn format_output_error(args: &[&str], expected: &str, actual: &str) -> String {
+ fn normalize_str(input: &str) -> &str {
+ if input.is_empty() { "<Empty>" } else { input }
+ }
+
+ let expected = normalize_str(expected);
+ let actual = normalize_str(actual);
+
+ format!(
+ concat!(
+ "`yt {}` did not produce the expected output.\n",
+ "expected:\n---\n{}\n---\n and actual:\n---\n{}\n---\n"
+ ),
+ args.join(" "),
+ expected,
+ actual
+ )
+}
+
+/// Normalize the output for comparison.
+fn normalize_output(s: &str, trim_start: bool, normalize_line: bool) -> String {
+ // Split into lines and normalize separators.
+ let mut lines = s
+ .replace('\0', "NULL\n")
+ .lines()
+ .map(|line| {
+ let line = if trim_start { line.trim_start() } else { line };
+ let line = line.replace('/', std::path::MAIN_SEPARATOR_STR);
+ if normalize_line {
+ let mut words: Vec<_> = line.split_whitespace().collect();
+ words.sort_unstable();
+ return words.join(" ");
+ }
+ line
+ })
+ .collect::<Vec<_>>();
+
+ lines.sort();
+ lines.join("\n")
+}
+
+impl TestEnv {
+ /// Assert that calling *yt* in the specified path under the root working directory,
+ /// and with the specified arguments produces the expected output.
+ pub(crate) fn assert_output(&self, args: &[&str], expected: &str) {
+ let expected = normalize_output(expected, true, false);
+ let actual = self.run(args);
+
+ assert!(
+ expected == actual,
+ "{}",
+ format_output_error(args, &expected, &actual)
+ );
+ }
+
+ /// Run *yt* once with the first args, pipe the output of this command to *yt* with the second
+ /// args and return the normalized output.
+ pub(crate) fn run_piped(&self, first_args: &[&str], second_args: &[&str]) -> String {
+ let mut first_cmd = self.prepare_yt(first_args);
+ let mut second_cmd = self.prepare_yt(second_args);
+
+ first_cmd.stdout(Stdio::piped());
+ let mut first_child = first_cmd.spawn().expect("yt spawn");
+
+ second_cmd.stdin(first_child.stdout.take().expect("Was set"));
+ let second_output = Self::finalize_cmd(second_cmd, second_args);
+
+ let first_output = first_child.wait_with_output().expect("yt run");
+ assert!(
+ first_output.status.success(),
+ "{}",
+ format_exit_error(first_args, &first_output)
+ );
+
+ second_output
+ }
+
+ /// Run *yt*, with the given args.
+ /// Returns the normalized stdout output.
+ pub(crate) fn run(&self, args: &[&str]) -> String {
+ let cmd = self.prepare_yt(args);
+ Self::finalize_cmd(cmd, args)
+ }
+
+ /// Run *yt*, with the given args and environment variables.
+ /// Returns the normalized stdout output.
+ pub(crate) fn run_env(&self, args: &[&str], env: &HashMap<&str, &str>) -> String {
+ let mut cmd = self.prepare_yt(args);
+ cmd.envs(env.iter());
+ Self::finalize_cmd(cmd, args)
+ }
+
+ /// Run *yt*, with the given args and fork into the background.
+ /// Returns a sender for the lines on stdout.
+ pub(crate) fn run_background(&mut self, args: &[&str]) -> process::ChildStdout {
+ let mut cmd = self.prepare_yt(args);
+
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::inherit());
+
+ // The whole point of this function, is calling a command and keep it running for the whole
+ // programs run-time.
+ //
+ // And we provide a clean-up mechanism.
+ #[allow(clippy::zombie_processes)]
+ let mut child = cmd.spawn().expect("yt spawn");
+
+ self.spawned_childs.push(child.id());
+
+ child.stdout.take().expect("Was piped")
+ }
+
+ fn finalize_cmd(mut cmd: process::Command, args: &[&str]) -> String {
+ let child = cmd.spawn().expect("yt spawn");
+ let output = child.wait_with_output().expect("yt output");
+
+ assert!(
+ output.status.success(),
+ "{}",
+ format_exit_error(args, &output)
+ );
+
+ normalize_output(&String::from_utf8_lossy(&output.stdout), false, false)
+ }
+
+ fn prepare_yt(&self, args: &[&str]) -> process::Command {
+ let mut cmd = process::Command::new(&self.yt_exe);
+
+ cmd.current_dir(&self.test_dir);
+
+ cmd.args([
+ "-v",
+ "--color",
+ "false",
+ "--config-path",
+ self.paths.config.to_str().unwrap(),
+ ]);
+
+ cmd.stdout(Stdio::piped());
+ cmd.stderr(Stdio::piped());
+
+ cmd.args(args);
+
+ eprintln!("{} `yt {}`", self.name.blue().italic(), args.join(" "));
+
+ cmd
+ }
+}
diff --git a/crates/yt/tests/_testenv/util.rs b/crates/yt/tests/_testenv/util.rs
new file mode 100644
index 0000000..b811bf7
--- /dev/null
+++ b/crates/yt/tests/_testenv/util.rs
@@ -0,0 +1,361 @@
+use crate::_testenv::TestEnv;
+
+use std::{
+ collections::HashMap,
+ fs::{OpenOptions, Permissions},
+ io::Write,
+ os::unix::fs::PermissionsExt,
+ path::PathBuf,
+};
+
+pub(crate) fn get_first_hash(env: &TestEnv) -> String {
+ let output = env.run(&["videos", "ls", "--format", "{extractor_hash}"]);
+
+ let first_hash = output.lines().next().unwrap();
+ first_hash.to_owned()
+}
+
+#[derive(Clone, Copy)]
+pub(crate) enum Subscription {
+ Tagesschau,
+}
+
+impl Subscription {
+ const fn as_url(self) -> &'static str {
+ match self {
+ Subscription::Tagesschau => {
+ "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU"
+ }
+ }
+ }
+
+ const fn as_name(self) -> &'static str {
+ match self {
+ Subscription::Tagesschau => "Tagesschau",
+ }
+ }
+}
+
+/// Provide the given number of videos.
+pub(crate) fn provide_videos(env: &TestEnv, sub: Subscription, num: u8) {
+ add_sub(env, sub);
+ update_sub(env, num, sub);
+}
+
+pub(crate) fn add_sub(env: &TestEnv, sub: Subscription) {
+ env.run(&[
+ "subs",
+ "add",
+ sub.as_url(),
+ "--name",
+ sub.as_name(),
+ "--no-check",
+ ]);
+}
+
+pub(crate) fn update_sub(env: &TestEnv, num_of_videos: u8, sub: Subscription) {
+ let num_of_videos: &str = u8_to_char(num_of_videos);
+
+ env.run(&["update", "--max-backlog", num_of_videos, sub.as_name()]);
+}
+
+pub(crate) fn run_select(env: &TestEnv, sed_regex: &str) {
+ let mut map = HashMap::new();
+
+ let command = make_command(
+ env,
+ format!(r#"sed --in-place '{sed_regex}' "$1""#).as_str(),
+ );
+ map.insert("EDITOR", command.to_str().unwrap());
+
+ env.run_env(&["select", "file", "--done"], &map);
+}
+
+fn make_command(env: &TestEnv, shell_command: &str) -> PathBuf {
+ let command_path = env.test_dir.join("command.sh");
+
+ {
+ let mut file = OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(&command_path)
+ .unwrap();
+
+ writeln!(file, "#!/usr/bin/env sh").unwrap();
+
+ file.write_all(shell_command.as_bytes()).unwrap();
+ file.flush().unwrap();
+
+ {
+ let perms = Permissions::from_mode(0o0700);
+ file.set_permissions(perms).unwrap();
+ }
+ }
+
+ command_path
+}
+
+// Char conversion {{{
+#[allow(clippy::too_many_lines)]
+const fn u8_to_char(input: u8) -> &'static str {
+ match input {
+ 0 => "0",
+ 1 => "1",
+ 2 => "2",
+ 3 => "3",
+ 4 => "4",
+ 5 => "5",
+ 6 => "6",
+ 7 => "7",
+ 8 => "8",
+ 9 => "9",
+ 10 => "10",
+ 11 => "11",
+ 12 => "12",
+ 13 => "13",
+ 14 => "14",
+ 15 => "15",
+ 16 => "16",
+ 17 => "17",
+ 18 => "18",
+ 19 => "19",
+ 20 => "20",
+ 21 => "21",
+ 22 => "22",
+ 23 => "23",
+ 24 => "24",
+ 25 => "25",
+ 26 => "26",
+ 27 => "27",
+ 28 => "28",
+ 29 => "29",
+ 30 => "30",
+ 31 => "31",
+ 32 => "32",
+ 33 => "33",
+ 34 => "34",
+ 35 => "35",
+ 36 => "36",
+ 37 => "37",
+ 38 => "38",
+ 39 => "39",
+ 40 => "40",
+ 41 => "41",
+ 42 => "42",
+ 43 => "43",
+ 44 => "44",
+ 45 => "45",
+ 46 => "46",
+ 47 => "47",
+ 48 => "48",
+ 49 => "49",
+ 50 => "50",
+ 51 => "51",
+ 52 => "52",
+ 53 => "53",
+ 54 => "54",
+ 55 => "55",
+ 56 => "56",
+ 57 => "57",
+ 58 => "58",
+ 59 => "59",
+ 60 => "60",
+ 61 => "61",
+ 62 => "62",
+ 63 => "63",
+ 64 => "64",
+ 65 => "65",
+ 66 => "66",
+ 67 => "67",
+ 68 => "68",
+ 69 => "69",
+ 70 => "70",
+ 71 => "71",
+ 72 => "72",
+ 73 => "73",
+ 74 => "74",
+ 75 => "75",
+ 76 => "76",
+ 77 => "77",
+ 78 => "78",
+ 79 => "79",
+ 80 => "80",
+ 81 => "81",
+ 82 => "82",
+ 83 => "83",
+ 84 => "84",
+ 85 => "85",
+ 86 => "86",
+ 87 => "87",
+ 88 => "88",
+ 89 => "89",
+ 90 => "90",
+ 91 => "91",
+ 92 => "92",
+ 93 => "93",
+ 94 => "94",
+ 95 => "95",
+ 96 => "96",
+ 97 => "97",
+ 98 => "98",
+ 99 => "99",
+ 100 => "100",
+ 101 => "101",
+ 102 => "102",
+ 103 => "103",
+ 104 => "104",
+ 105 => "105",
+ 106 => "106",
+ 107 => "107",
+ 108 => "108",
+ 109 => "109",
+ 110 => "110",
+ 111 => "111",
+ 112 => "112",
+ 113 => "113",
+ 114 => "114",
+ 115 => "115",
+ 116 => "116",
+ 117 => "117",
+ 118 => "118",
+ 119 => "119",
+ 120 => "120",
+ 121 => "121",
+ 122 => "122",
+ 123 => "123",
+ 124 => "124",
+ 125 => "125",
+ 126 => "126",
+ 127 => "127",
+ 128 => "128",
+ 129 => "129",
+ 130 => "130",
+ 131 => "131",
+ 132 => "132",
+ 133 => "133",
+ 134 => "134",
+ 135 => "135",
+ 136 => "136",
+ 137 => "137",
+ 138 => "138",
+ 139 => "139",
+ 140 => "140",
+ 141 => "141",
+ 142 => "142",
+ 143 => "143",
+ 144 => "144",
+ 145 => "145",
+ 146 => "146",
+ 147 => "147",
+ 148 => "148",
+ 149 => "149",
+ 150 => "150",
+ 151 => "151",
+ 152 => "152",
+ 153 => "153",
+ 154 => "154",
+ 155 => "155",
+ 156 => "156",
+ 157 => "157",
+ 158 => "158",
+ 159 => "159",
+ 160 => "160",
+ 161 => "161",
+ 162 => "162",
+ 163 => "163",
+ 164 => "164",
+ 165 => "165",
+ 166 => "166",
+ 167 => "167",
+ 168 => "168",
+ 169 => "169",
+ 170 => "170",
+ 171 => "171",
+ 172 => "172",
+ 173 => "173",
+ 174 => "174",
+ 175 => "175",
+ 176 => "176",
+ 177 => "177",
+ 178 => "178",
+ 179 => "179",
+ 180 => "180",
+ 181 => "181",
+ 182 => "182",
+ 183 => "183",
+ 184 => "184",
+ 185 => "185",
+ 186 => "186",
+ 187 => "187",
+ 188 => "188",
+ 189 => "189",
+ 190 => "190",
+ 191 => "191",
+ 192 => "192",
+ 193 => "193",
+ 194 => "194",
+ 195 => "195",
+ 196 => "196",
+ 197 => "197",
+ 198 => "198",
+ 199 => "199",
+ 200 => "200",
+ 201 => "201",
+ 202 => "202",
+ 203 => "203",
+ 204 => "204",
+ 205 => "205",
+ 206 => "206",
+ 207 => "207",
+ 208 => "208",
+ 209 => "209",
+ 210 => "210",
+ 211 => "211",
+ 212 => "212",
+ 213 => "213",
+ 214 => "214",
+ 215 => "215",
+ 216 => "216",
+ 217 => "217",
+ 218 => "218",
+ 219 => "219",
+ 220 => "220",
+ 221 => "221",
+ 222 => "222",
+ 223 => "223",
+ 224 => "224",
+ 225 => "225",
+ 226 => "226",
+ 227 => "227",
+ 228 => "228",
+ 229 => "229",
+ 230 => "230",
+ 231 => "231",
+ 232 => "232",
+ 233 => "233",
+ 234 => "234",
+ 235 => "235",
+ 236 => "236",
+ 237 => "237",
+ 238 => "238",
+ 239 => "239",
+ 240 => "240",
+ 241 => "241",
+ 242 => "242",
+ 243 => "243",
+ 244 => "244",
+ 245 => "245",
+ 246 => "246",
+ 247 => "247",
+ 248 => "248",
+ 249 => "249",
+ 250 => "250",
+ 251 => "251",
+ 252 => "252",
+ 253 => "253",
+ 254 => "254",
+ 255 => "255",
+ }
+}
+// }}}
diff --git a/crates/yt/tests/select/base.rs b/crates/yt/tests/select/base.rs
new file mode 100644
index 0000000..dddf721
--- /dev/null
+++ b/crates/yt/tests/select/base.rs
@@ -0,0 +1,40 @@
+use std::{collections::HashMap, fs};
+
+use crate::{
+ _testenv::{
+ TestEnv,
+ util::{self, Subscription},
+ },
+ select::get_videos_in_state,
+};
+
+#[test]
+fn test_base() {
+ let env = TestEnv::new(module_path!());
+
+ util::provide_videos(&env, Subscription::Tagesschau, 4);
+
+ let first_hash = &util::get_first_hash(&env);
+ env.run(&["select", "drop", first_hash]);
+
+ let mut map = HashMap::new();
+ map.insert("EDITOR", "true");
+ env.run_env(&["select", "file", "--done"], &map);
+
+ fs::remove_file(&env.paths.last_selection).unwrap();
+
+ env.run_env(&["select", "split", "--done"], &map);
+ assert_states(&env);
+
+ env.run_env(&["select", "file", "--use-last-selection"], &map);
+ assert_states(&env);
+}
+
+fn assert_states(env: &TestEnv) {
+ assert_eq!(get_videos_in_state(env, "Picked"), 3);
+ assert_eq!(get_videos_in_state(env, "Drop"), 1);
+
+ assert_eq!(get_videos_in_state(env, "Watch"), 0);
+ assert_eq!(get_videos_in_state(env, "Cached"), 0);
+ assert_eq!(get_videos_in_state(env, "Watched"), 0);
+}
diff --git a/crates/yt/tests/select/file.rs b/crates/yt/tests/select/file.rs
new file mode 100644
index 0000000..320b27f
--- /dev/null
+++ b/crates/yt/tests/select/file.rs
@@ -0,0 +1,21 @@
+use crate::{
+ _testenv::util::{self, Subscription},
+ select::get_videos_in_state,
+ testenv::TestEnv,
+};
+
+#[test]
+fn test_file() {
+ let env = TestEnv::new(module_path!());
+
+ util::provide_videos(&env, Subscription::Tagesschau, 4);
+
+ util::run_select(&env, "s/pick/watch/");
+
+ assert_eq!(get_videos_in_state(&env, "Picked"), 0);
+ assert_eq!(get_videos_in_state(&env, "Drop"), 0);
+
+ assert_eq!(get_videos_in_state(&env, "Watch"), 4);
+ assert_eq!(get_videos_in_state(&env, "Cached"), 0);
+ assert_eq!(get_videos_in_state(&env, "Watched"), 0);
+}
diff --git a/crates/yt/tests/select/mod.rs b/crates/yt/tests/select/mod.rs
new file mode 100644
index 0000000..9ee23f5
--- /dev/null
+++ b/crates/yt/tests/select/mod.rs
@@ -0,0 +1,15 @@
+use crate::_testenv::TestEnv;
+
+mod base;
+mod file;
+mod options;
+
+fn get_videos_in_state(env: &TestEnv, state: &str) -> usize {
+ let status = env.run(&[
+ "status",
+ "--format",
+ format!("{{{}_videos_len}}", state.to_lowercase()).as_str(),
+ ]);
+
+ status.parse().unwrap()
+}
diff --git a/crates/yt/tests/select/options.rs b/crates/yt/tests/select/options.rs
new file mode 100644
index 0000000..f9456c0
--- /dev/null
+++ b/crates/yt/tests/select/options.rs
@@ -0,0 +1,41 @@
+use crate::{_testenv::util, select::get_videos_in_state, testenv::TestEnv};
+
+#[test]
+fn test_options() {
+ let env = TestEnv::new(module_path!());
+
+ util::provide_videos(&env, util::Subscription::Tagesschau, 1);
+
+ let video_hash = &util::get_first_hash(&env);
+ env.run(&["select", "watch", video_hash]);
+
+ env.assert_output(&["videos", "info", video_hash, "--format", "{options}"], "");
+
+ env.run(&[
+ "select",
+ "watch",
+ video_hash,
+ "--playback-speed",
+ "1",
+ "--subtitle-langs",
+ "en,de,sv",
+ ]);
+
+ env.assert_output(
+ &["videos", "info", video_hash, "--format", "{options}"],
+ "--playback-speed '1' --subtitle-langs 'en,de,sv'",
+ );
+
+ env.run(&["select", "watch", video_hash, "-s", "1.7", "-l", "de"]);
+
+ env.assert_output(
+ &["videos", "info", video_hash, "--format", "{options}"],
+ "--playback-speed '1.7' --subtitle-langs 'de'",
+ );
+
+ assert_eq!(get_videos_in_state(&env, "Picked"), 0);
+ assert_eq!(get_videos_in_state(&env, "Drop"), 0);
+ assert_eq!(get_videos_in_state(&env, "Watch"), 1);
+ assert_eq!(get_videos_in_state(&env, "Cached"), 0);
+ assert_eq!(get_videos_in_state(&env, "Watched"), 0);
+}
diff --git a/crates/yt/tests/subscriptions/import_export/golden.txt b/crates/yt/tests/subscriptions/import_export/golden.txt
new file mode 100644
index 0000000..7ed5419
--- /dev/null
+++ b/crates/yt/tests/subscriptions/import_export/golden.txt
@@ -0,0 +1,2 @@
+Nyheter på lätt svenska: 'https://www.svtplay.se/nyheter-pa-latt-svenska'
+tagesschau: 'https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU'
diff --git a/crates/yt/tests/subscriptions/import_export/mod.rs b/crates/yt/tests/subscriptions/import_export/mod.rs
new file mode 100644
index 0000000..911f09f
--- /dev/null
+++ b/crates/yt/tests/subscriptions/import_export/mod.rs
@@ -0,0 +1,24 @@
+use crate::testenv::TestEnv;
+
+#[test]
+fn test_import_export() {
+ let env = TestEnv::new(module_path!());
+
+ env.run(&[
+ "subs",
+ "add",
+ "https://www.svtplay.se/nyheter-pa-latt-svenska",
+ ]);
+ env.run(&[
+ "subs",
+ "add",
+ "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU",
+ ]);
+
+ let before = env.run(&["subs", "list"]);
+
+ env.run_piped(&["subs", "export"], &["subs", "import", "--force"]);
+
+ env.assert_output(&["subs", "list"], &before);
+ env.assert_output(&["subs", "list"], include_str!("./golden.txt"));
+}
diff --git a/crates/yt/tests/subscriptions/mod.rs b/crates/yt/tests/subscriptions/mod.rs
new file mode 100644
index 0000000..5b0157f
--- /dev/null
+++ b/crates/yt/tests/subscriptions/mod.rs
@@ -0,0 +1,2 @@
+mod import_export;
+mod naming_subscriptions;
diff --git a/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt
new file mode 100644
index 0000000..46ede50
--- /dev/null
+++ b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt
@@ -0,0 +1,2 @@
+Nyheter: 'https://www.svtplay.se/nyheter-pa-latt-svenska'
+Vewn: 'https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU'
diff --git a/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs b/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs
new file mode 100644
index 0000000..255a575
--- /dev/null
+++ b/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs
@@ -0,0 +1,23 @@
+use crate::testenv::TestEnv;
+
+#[test]
+fn test_naming_subscriptions() {
+ let env = TestEnv::new(module_path!());
+
+ env.run(&[
+ "subs",
+ "add",
+ "https://www.svtplay.se/nyheter-pa-latt-svenska",
+ "--name",
+ "Nyheter",
+ ]);
+ env.run(&[
+ "subs",
+ "add",
+ "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU",
+ "--name",
+ "Vewn",
+ ]);
+
+ env.assert_output(&["subs", "list"], include_str!("./golden.txt"));
+}
diff --git a/crates/yt/tests/tests.rs b/crates/yt/tests/tests.rs
new file mode 100644
index 0000000..9e6c95a
--- /dev/null
+++ b/crates/yt/tests/tests.rs
@@ -0,0 +1,12 @@
+// Use this, for the background run pids
+// #![feature(linux_pidfd)]
+
+#![allow(unused_crate_dependencies)]
+
+mod _testenv;
+pub(crate) use _testenv as testenv;
+
+mod select;
+mod subscriptions;
+mod videos;
+mod watch;
diff --git a/crates/yt/tests/videos/downloading.rs b/crates/yt/tests/videos/downloading.rs
new file mode 100644
index 0000000..4b923d4
--- /dev/null
+++ b/crates/yt/tests/videos/downloading.rs
@@ -0,0 +1,42 @@
+use crate::{_testenv::util, testenv::TestEnv};
+
+#[test]
+fn test_downloading() {
+ let env = TestEnv::new(module_path!());
+
+ util::provide_videos(&env, util::Subscription::Tagesschau, 1);
+
+ let first_hash = &util::get_first_hash(&env);
+ env.run(&["select", "watch", first_hash]);
+
+ env.run(&["download"]);
+
+ let usage = get_cache_usage(&env);
+ assert!(usage > 0.0);
+
+ env.run(&["database", "invalidate"]);
+
+ let usage = get_cache_usage(&env);
+
+ #[allow(clippy::float_cmp)]
+ {
+ assert_eq!(usage, 0.0);
+ }
+}
+
+fn get_cache_usage(env: &TestEnv) -> f64 {
+ let status = env.run(&["status", "--format", "{cache_usage}"]);
+
+ let split: Vec<_> = status.split(' ').collect();
+ let usage: f64 = split[0].parse().unwrap();
+ let unit = split[1];
+
+ #[allow(clippy::cast_precision_loss)]
+ match unit {
+ "B" => usage * (1024u64.pow(0)) as f64,
+ "KiB" => usage * (1024u64.pow(1)) as f64,
+ "MiB" => usage * (1024u64.pow(2)) as f64,
+ "GiB" => usage * (1024u64.pow(3)) as f64,
+ other => unreachable!("Unknown unit: {other}"),
+ }
+}
diff --git a/crates/yt/tests/videos/mod.rs b/crates/yt/tests/videos/mod.rs
new file mode 100644
index 0000000..1077d05
--- /dev/null
+++ b/crates/yt/tests/videos/mod.rs
@@ -0,0 +1 @@
+mod downloading;
diff --git a/crates/yt/tests/watch/focus_switch.rs b/crates/yt/tests/watch/focus_switch.rs
new file mode 100644
index 0000000..e976859
--- /dev/null
+++ b/crates/yt/tests/watch/focus_switch.rs
@@ -0,0 +1,42 @@
+use yt_dlp::json_cast;
+
+use crate::{_testenv::util, testenv::TestEnv, watch::MpvControl};
+
+#[test]
+fn test_focus_switch() {
+ let mut env = TestEnv::new(module_path!());
+
+ {
+ util::provide_videos(&env, util::Subscription::Tagesschau, 32);
+
+ util::run_select(&env, "s/pick/watch/");
+
+ env.run(&["download"]);
+ }
+
+ let mut mpv = MpvControl::new(&mut env);
+
+ assert_pos(&mut mpv, 0);
+
+ for i in 1..32 {
+ mpv.assert(&["playlist-next", "weak"]);
+ assert_pos(&mut mpv, i);
+ }
+
+ mpv.assert(&["playlist-next", "weak"]);
+ assert_pos(&mut mpv, 2);
+
+ mpv.assert(&["playlist-prev", "weak"]);
+ assert_pos(&mut mpv, 1);
+
+ mpv.assert(&["playlist-prev", "weak"]);
+ assert_pos(&mut mpv, 0);
+
+ mpv.assert(&["playlist-prev", "weak"]);
+ assert_pos(&mut mpv, 0);
+}
+
+fn assert_pos(mpv: &mut MpvControl, pos: i64) {
+ let mpv_pos = mpv.assert(&["get_property", "playlist-pos"]);
+ assert_eq!(json_cast!(mpv_pos, as_i64), pos);
+}
diff --git a/crates/yt/tests/watch/mod.rs b/crates/yt/tests/watch/mod.rs
new file mode 100644
index 0000000..534c210
--- /dev/null
+++ b/crates/yt/tests/watch/mod.rs
@@ -0,0 +1,121 @@
+use std::{
+ cell::Cell,
+ io::{BufRead, BufReader, Write},
+ os::unix::net::UnixStream,
+ path::PathBuf,
+};
+
+use owo_colors::OwoColorize;
+use serde_json::json;
+use yt_dlp::{json_cast, json_get, progress_hook::__priv::vm::common::atomic::Radium};
+
+use crate::_testenv::TestEnv;
+
+mod focus_switch;
+
+struct MpvControl {
+ stream: UnixStream,
+ current_request_id: Cell<u64>,
+ name: &'static str,
+}
+
+impl MpvControl {
+ fn new(env: &mut TestEnv) -> Self {
+ let socket_path = {
+ let stdout = env.run_background(&[
+ "watch",
+ // "--headless",
+ "--provide-ipc-socket",
+ ]);
+
+ let line = {
+ let mut buf = String::new();
+ let mut reader = BufReader::new(stdout);
+ reader.read_line(&mut buf).expect("In-memory");
+ buf
+ };
+
+ PathBuf::from(line.trim())
+ };
+
+ let stream = UnixStream::connect(&socket_path).unwrap_or_else(|e| {
+ panic!(
+ "Path to socket ('{}') should exist, but did not: {e}",
+ socket_path.display()
+ )
+ });
+
+ let mut me = Self {
+ stream,
+ name: env.name,
+ current_request_id: Cell::new(0),
+ };
+
+ // Disable all events.
+ // We do not use them, and this should reduce the read load on the socket.
+ me.assert(&["disable_event", "all"]);
+
+ me
+ }
+
+ /// Run a command and assert that it ran successfully.
+ fn assert(&mut self, args: &[&str]) -> serde_json::Value {
+ let out = self.command(args);
+
+ out.unwrap_or_else(|e| panic!("`mpv {}` failed; error {e}.", args.join(" ")))
+ }
+
+ /// Run a command in mpv.
+ /// Will return true if the command ran correctly and false if not.
+ fn command(&mut self, args: &[&str]) -> Result<serde_json::Value, String> {
+ let tl_rid = self
+ .current_request_id
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+
+ eprint!("{} `mpv {}`", self.name.blue().italic(), args.join(" "));
+
+ writeln!(
+ self.stream,
+ "{}",
+ json!( { "command": args, "request_id": tl_rid })
+ )
+ .expect("Should always work");
+
+ loop {
+ let response: serde_json::Value = {
+ let mut reader = BufReader::new(&mut self.stream);
+
+ let mut buf = String::new();
+ reader.read_line(&mut buf).expect("Works");
+ serde_json::from_str(&buf).expect("Mpv only returns json")
+ };
+
+ if let Some(rid) = response.get("request_id") {
+ if json_cast!(rid, as_u64) == tl_rid {
+ let error = json_get!(response, "error", as_str);
+
+ if error == "success" {
+ let data: serde_json::Value = {
+ match response.get("data") {
+ Some(val) => val.to_owned(),
+ None => serde_json::Value::Null,
+ }
+ };
+
+ eprintln!(", {}: {data}", "output".bright_blue(),);
+ return Ok(data);
+ }
+
+ eprintln!(", {}: {error}", "error".bright_red());
+ return Err(error.to_owned());
+ }
+ }
+ }
+ }
+}
+
+impl Drop for MpvControl {
+ fn drop(&mut self) {
+ self.assert(&["quit"]);
+ }
+}