about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-07-15 07:23:06 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2025-07-15 07:23:06 +0200
commit234b9105e097fb63f636bc05ac2f471c001c3aac (patch)
treecdf5d365165058c1eb8e1041fb8603cdff4aeece
parentfix(crates/yt/select): Correctly open the persistent file in `select split` (diff)
downloadyt-234b9105e097fb63f636bc05ac2f471c001c3aac.zip
test(crates/yt): Add basic integration tests
-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"]);
+    }
+}