From 474c03cc628bafe86c30f5cf3ecde8c891644080 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 19:52:41 -0300 Subject: [PATCH 1/9] refactor(rules): a test file leaves the ceiling only if a #[cfg(test)] declares it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests are about to move into sibling files, and a sibling is a file: without this, `crates/cp-store/src/store_test.rs` would arrive at 1.617 lines against a 1.500 ceiling and the first file moved would break CI. Excluding them by name would be worse than the problem — anything could dodge the ceiling by calling itself `_test.rs`. So the exemption is the one Tisty uses: a file is skipped when some `#[cfg(test)]` in the tree names it in a `#[path = "..."]`. The declaration earns it, not the name, and the list comes out of the source rather than a pattern. It measures nothing differently today: `--print` gives byte-identical output before and after, because nothing declares a path yet. What it does is make the next commits possible, and it is checked both ways — a 1.601-line sibling behind a declaration passes, the same file with nobody declaring it is refused at both the file and the function ceiling. --- scripts/oversized.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/scripts/oversized.py b/scripts/oversized.py index 9aaabe5b..eee9faa9 100644 --- a/scripts/oversized.py +++ b/scripts/oversized.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import os import pathlib import re import sys @@ -13,6 +14,7 @@ CHARRED = re.compile(r"'(?:[^'\\]|\\.)'") NOTED = re.compile(r"//.*$") ONLY_IN_TESTS = re.compile(r"#\[cfg\((all\()?\s*test\b") +DECLARED = re.compile(r'#\[path\s*=\s*"([^"]+)"\]') HEADED = re.compile( r"(?P\s*)(pub(\([^)]*\))? )?(default )?(const )?(async )?(unsafe )?" r'(extern "[A-Za-z-]+" )?fn (?P\w+)' @@ -102,8 +104,28 @@ def held_of(at: pathlib.Path) -> dict[str, int]: return found +def only_tests() -> set[str]: + said = set() + for where in WHERE: + base = pathlib.Path(where) + if not base.is_dir(): + continue + for at in base.rglob("*.rs"): + lines = at.read_text(encoding="utf-8", errors="replace").splitlines() + for turn, line in enumerate(lines): + if not ONLY_IN_TESTS.match(line.strip()): + continue + for ahead in lines[turn + 1:turn + 4]: + found = DECLARED.search(ahead) + if found: + said.add(pathlib.Path(os.path.normpath(at.parent / found.group(1))).as_posix()) + break + return said + + def reaching() -> list[pathlib.Path]: every = [] + apart = only_tests() for where in WHERE: base = pathlib.Path(where) if not base.is_dir(): @@ -114,6 +136,8 @@ def reaching() -> list[pathlib.Path]: said = at.as_posix() if "node_modules" in said or "/tests/" in said or ".test." in said: continue + if said in apart: + continue if any(said.endswith(one) for one in TABLES): continue if where == "crates" and "/src/" not in said and "/examples/" not in said: From f3733100b3a4176c7de42d76a05e1fd5b77d4c00 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 19:53:00 -0300 Subject: [PATCH 2/9] =?UTF-8?q?refactor(core):=20the=20tests=20leave=20the?= =?UTF-8?q?=20files=20they=20test=20=E2=80=94=20cp-config,=20cp-core,=20cp?= =?UTF-8?q?-store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35 test modules out of 21 files, 10.117 lines that move without one of them changing. `store.rs` carried 3.360 lines of test over 1.449 of code; reading it meant scrolling past a small book. The pattern is Tisty's, from the task that put a ceiling on its files: #[cfg(test)] #[path = "store_test.rs"] mod tests; The sibling lives in the same `src/`, without the `mod tests { }` wrapper and with the body dedented one level. It is still a child module, so `use super::*` and the private access work exactly as before. A module called `tests` becomes `_test.rs`; one with a name of its own keeps it — `store_listing.rs`, `kind_borders.rs`, `kind_inherited_from_2x.rs`. **That nothing changed is not a claim, it is a comparison.** Every one of the 94 siblings was re-indented and diffed against the body it came from in `origin/main`: 94 compared, 0 different. Then `cargo fmt` reflows six of them, because four columns of freed indentation let lines that rustfmt had split fit on one again — which is why the comparison is run before the formatter, not after. Per crate, the count of tests is the arbiter: cp-config 16 → 16, cp-core 301 → 301, cp-store 268 → 268. **And it answers the question the task asked.** The ceiling was supposed to be measuring code only; if the numbers moved, it had been measuring wrong. They do not: `store.rs` reads 1.409 before and 1.409 after, and so does every other file over 500 lines. The 19-line difference across the whole tree is the six files rustfmt reflowed. --- crates/cp-config/src/lib.rs | 195 +- crates/cp-config/src/lib_test.rs | 191 + crates/cp-core/src/capture.rs | 102 +- crates/cp-core/src/capture_test.rs | 98 + crates/cp-core/src/destination.rs | 74 +- crates/cp-core/src/destination_test.rs | 70 + crates/cp-core/src/dib.rs | 576 +-- crates/cp-core/src/dib_properties.rs | 35 + crates/cp-core/src/dib_test.rs | 533 +++ crates/cp-core/src/formats.rs | 454 +-- crates/cp-core/src/formats_properties.rs | 115 + crates/cp-core/src/formats_test.rs | 222 ++ crates/cp-core/src/formats_windows_needs.rs | 105 + crates/cp-core/src/hash.rs | 109 +- crates/cp-core/src/hash_test.rs | 105 + crates/cp-core/src/identity.rs | 103 +- crates/cp-core/src/identity_test.rs | 98 + crates/cp-core/src/item.rs | 328 +- crates/cp-core/src/item_properties.rs | 49 + crates/cp-core/src/item_test.rs | 271 ++ crates/cp-core/src/kind.rs | 374 +- crates/cp-core/src/kind_borders.rs | 117 + crates/cp-core/src/kind_inherited_from_2x.rs | 64 + crates/cp-core/src/kind_redundancy.rs | 28 + crates/cp-core/src/kind_test.rs | 149 + crates/cp-core/src/paste.rs | 180 +- crates/cp-core/src/paste_as.rs | 1226 +------ crates/cp-core/src/paste_as_test.rs | 1220 +++++++ crates/cp-core/src/paste_properties.rs | 52 + crates/cp-core/src/paste_test.rs | 120 + crates/cp-core/src/search.rs | 257 +- crates/cp-core/src/search_test.rs | 253 ++ crates/cp-core/src/thumbnail.rs | 127 +- crates/cp-core/src/thumbnail_test.rs | 123 + crates/cp-core/src/token.rs | 155 +- crates/cp-core/src/token_test.rs | 150 + crates/cp-core/src/watch.rs | 429 +-- crates/cp-core/src/watch_insisting.rs | 133 + crates/cp-core/src/watch_properties.rs | 106 + crates/cp-core/src/watch_test.rs | 106 + crates/cp-core/src/watch_windows_counter.rs | 68 + crates/cp-store/src/backup.rs | 382 +- crates/cp-store/src/backup_test.rs | 378 ++ crates/cp-store/src/blobs.rs | 271 +- crates/cp-store/src/blobs_test.rs | 267 ++ crates/cp-store/src/legacy.rs | 948 +---- crates/cp-store/src/legacy_test.rs | 944 +++++ crates/cp-store/src/query.rs | 277 +- crates/cp-store/src/query_test.rs | 273 ++ crates/cp-store/src/schema.rs | 326 +- crates/cp-store/src/schema_test.rs | 322 ++ crates/cp-store/src/store.rs | 3372 +----------------- crates/cp-store/src/store_housekeeping.rs | 714 ++++ crates/cp-store/src/store_identity.rs | 75 + crates/cp-store/src/store_listing.rs | 948 +++++ crates/cp-store/src/store_test.rs | 1615 +++++++++ 56 files changed, 10189 insertions(+), 10193 deletions(-) create mode 100644 crates/cp-config/src/lib_test.rs create mode 100644 crates/cp-core/src/capture_test.rs create mode 100644 crates/cp-core/src/destination_test.rs create mode 100644 crates/cp-core/src/dib_properties.rs create mode 100644 crates/cp-core/src/dib_test.rs create mode 100644 crates/cp-core/src/formats_properties.rs create mode 100644 crates/cp-core/src/formats_test.rs create mode 100644 crates/cp-core/src/formats_windows_needs.rs create mode 100644 crates/cp-core/src/hash_test.rs create mode 100644 crates/cp-core/src/identity_test.rs create mode 100644 crates/cp-core/src/item_properties.rs create mode 100644 crates/cp-core/src/item_test.rs create mode 100644 crates/cp-core/src/kind_borders.rs create mode 100644 crates/cp-core/src/kind_inherited_from_2x.rs create mode 100644 crates/cp-core/src/kind_redundancy.rs create mode 100644 crates/cp-core/src/kind_test.rs create mode 100644 crates/cp-core/src/paste_as_test.rs create mode 100644 crates/cp-core/src/paste_properties.rs create mode 100644 crates/cp-core/src/paste_test.rs create mode 100644 crates/cp-core/src/search_test.rs create mode 100644 crates/cp-core/src/thumbnail_test.rs create mode 100644 crates/cp-core/src/token_test.rs create mode 100644 crates/cp-core/src/watch_insisting.rs create mode 100644 crates/cp-core/src/watch_properties.rs create mode 100644 crates/cp-core/src/watch_test.rs create mode 100644 crates/cp-core/src/watch_windows_counter.rs create mode 100644 crates/cp-store/src/backup_test.rs create mode 100644 crates/cp-store/src/blobs_test.rs create mode 100644 crates/cp-store/src/legacy_test.rs create mode 100644 crates/cp-store/src/query_test.rs create mode 100644 crates/cp-store/src/schema_test.rs create mode 100644 crates/cp-store/src/store_housekeeping.rs create mode 100644 crates/cp-store/src/store_identity.rs create mode 100644 crates/cp-store/src/store_listing.rs create mode 100644 crates/cp-store/src/store_test.rs diff --git a/crates/cp-config/src/lib.rs b/crates/cp-config/src/lib.rs index 6912f79e..bccac5da 100644 --- a/crates/cp-config/src/lib.rs +++ b/crates/cp-config/src/lib.rs @@ -156,196 +156,5 @@ fn for_a_moment(why: &std::io::Error) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - fn a_dir() -> tempfile::TempDir { - tempfile::tempdir().expect("a temporary directory") - } - - #[test] - fn a_fresh_install_keeps_a_month_and_answers_to_a_shortcut() { - let config = Config::default(); - assert_eq!(config.keeps_days, Some(30)); - assert_eq!(config.images_quota_mb, None); - assert!(config.hides_when_left); - assert_eq!(config.theme, Theme::System); - assert_eq!(config.locale, None); - assert!(config.shortcut.ends_with("Alt+V"), "{}", config.shortcut); - } - - #[test] - fn nothing_written_yet_reads_as_the_defaults() { - let dir = a_dir(); - assert_eq!(read(&at(dir.path())).expect("reads"), Config::default()); - } - - #[test] - fn what_goes_in_comes_back() { - let dir = a_dir(); - let path = at(dir.path()); - let mine = Config { - locale: Some("es".into()), - theme: Theme::Dark, - shortcut: "Ctrl+Shift+V".into(), - hides_when_left: false, - keeps_days: None, - images_quota_mb: Some(512), - }; - write(&path, &mine).expect("writes"); - assert_eq!(read(&path).expect("reads"), mine); - } - - #[test] - fn the_file_is_written_where_nothing_existed() { - let dir = a_dir(); - let path = at(&dir.path().join("deeper")); - write(&path, &Config::default()).expect("writes"); - assert!(path.exists()); - assert_eq!(leftovers(&dir), 0); - } - - #[test] - fn two_writers_at_once_leave_one_good_file_and_no_leftovers() { - let dir = a_dir(); - let path = at(dir.path()); - std::thread::scope(|all| { - for turn in 0..8 { - let path = path.clone(); - all.spawn(move || { - let mine = Config { - keeps_days: Some(turn + 1), - ..Config::default() - }; - write(&path, &mine).expect("writes"); - }); - } - }); - let landed = read(&path).expect("reads"); - assert!(matches!(landed.keeps_days, Some(one) if (1..=8).contains(&one))); - assert_eq!(leftovers(&dir), 0); - } - - #[test] - fn a_rename_that_is_refused_for_good_is_not_retried_forever() { - let dir = a_dir(); - let path = at(dir.path()); - std::fs::create_dir(&path).expect("makes a directory"); - let why = write(&path, &Config::default()).expect_err("refuses"); - assert!(matches!(why, Error::File(_)), "{why}"); - assert_eq!(leftovers(&dir), 0); - } - - fn leftovers(dir: &tempfile::TempDir) -> usize { - std::fs::read_dir(dir.path()) - .expect("reads the directory") - .filter_map(Result::ok) - .filter(|one| one.file_name().to_string_lossy().ends_with(".new")) - .count() - } - - #[test] - fn a_half_written_file_fills_the_rest_with_the_defaults() { - let dir = a_dir(); - let path = at(dir.path()); - std::fs::write(&path, "theme = \"dark\"\n").expect("writes"); - let config = read(&path).expect("reads"); - assert_eq!(config.theme, Theme::Dark); - assert_eq!(config.keeps_days, Config::default().keeps_days); - assert_eq!(config.shortcut, Config::default().shortcut); - } - - #[test] - fn a_file_that_is_not_toml_says_so_instead_of_starting_over() { - let dir = a_dir(); - let path = at(dir.path()); - std::fs::write(&path, "theme = = dark").expect("writes"); - let why = read(&path).expect_err("refuses"); - assert!(matches!(why, Error::Malformed(_)), "{why}"); - assert!(why.to_string().contains("TOML"), "{why}"); - } - - #[test] - fn a_directory_where_the_file_should_be_is_not_mistaken_for_an_empty_one() { - let dir = a_dir(); - let path = at(dir.path()); - std::fs::create_dir(&path).expect("makes a directory"); - assert!(matches!(read(&path), Err(Error::File(_)))); - } - - #[test] - fn nothing_and_zero_mean_the_same_and_are_written_once() { - let asked = Config { - keeps_days: Some(0), - images_quota_mb: Some(0), - shortcut: " ".into(), - locale: Some(" ".into()), - ..Config::default() - } - .sane(); - assert_eq!(asked.keeps_days, None); - assert_eq!(asked.images_quota_mb, None); - assert_eq!(asked.shortcut, SHORTCUT); - assert_eq!(asked.locale, None); - } - - #[test] - fn what_is_read_is_sane_even_when_the_file_is_not() { - let dir = a_dir(); - let path = at(dir.path()); - std::fs::write(&path, "keeps-days = 0\nimages-quota-mb = 0\n").expect("writes"); - let config = read(&path).expect("reads"); - assert_eq!(config.keeps_days, None); - assert_eq!(config.images_quota_mb, None); - } - - #[test] - fn the_shortcut_loses_the_spaces_around_it() { - let asked = Config { - shortcut: " Ctrl+Shift+V ".into(), - ..Config::default() - } - .sane(); - assert_eq!(asked.shortcut, "Ctrl+Shift+V"); - } - - #[test] - fn the_file_reads_as_a_person_would_write_it() { - let dir = a_dir(); - let path = at(dir.path()); - write(&path, &Config::default()).expect("writes"); - let said = std::fs::read_to_string(&path).expect("reads"); - assert!(said.contains("theme = \"system\""), "{said}"); - assert!(said.contains("keeps-days = 30"), "{said}"); - } - - #[test] - fn nothing_written_as_null_is_taken_for_nothing_at_all() { - let asked: Config = serde_json::from_str( - r#"{"locale":null,"theme":"system","shortcut":"Ctrl+Alt+V","hides-when-left":true,"keeps-days":null,"images-quota-mb":null}"#, - ) - .expect("what the window sends gets in"); - assert_eq!(asked.keeps_days, None); - assert_eq!(asked.images_quota_mb, None); - } - - #[test] - fn keeping_it_forever_survives_the_file() { - let dir = a_dir(); - let path = at(dir.path()); - let mine = Config { - keeps_days: None, - ..Config::default() - }; - write(&path, &mine).expect("writes"); - let said = std::fs::read_to_string(&path).expect("reads"); - assert!(said.contains("keeps-days = 0"), "{said}"); - assert_eq!(read(&path).expect("reads").keeps_days, None); - } - - #[test] - fn the_path_hangs_from_the_folder_it_is_given() { - let where_it_is = at(Path::new("anywhere")); - assert_eq!(where_it_is, Path::new("anywhere").join("config.toml")); - } -} +#[path = "lib_test.rs"] +mod tests; diff --git a/crates/cp-config/src/lib_test.rs b/crates/cp-config/src/lib_test.rs new file mode 100644 index 00000000..767c4d8c --- /dev/null +++ b/crates/cp-config/src/lib_test.rs @@ -0,0 +1,191 @@ +use super::*; + +fn a_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("a temporary directory") +} + +#[test] +fn a_fresh_install_keeps_a_month_and_answers_to_a_shortcut() { + let config = Config::default(); + assert_eq!(config.keeps_days, Some(30)); + assert_eq!(config.images_quota_mb, None); + assert!(config.hides_when_left); + assert_eq!(config.theme, Theme::System); + assert_eq!(config.locale, None); + assert!(config.shortcut.ends_with("Alt+V"), "{}", config.shortcut); +} + +#[test] +fn nothing_written_yet_reads_as_the_defaults() { + let dir = a_dir(); + assert_eq!(read(&at(dir.path())).expect("reads"), Config::default()); +} + +#[test] +fn what_goes_in_comes_back() { + let dir = a_dir(); + let path = at(dir.path()); + let mine = Config { + locale: Some("es".into()), + theme: Theme::Dark, + shortcut: "Ctrl+Shift+V".into(), + hides_when_left: false, + keeps_days: None, + images_quota_mb: Some(512), + }; + write(&path, &mine).expect("writes"); + assert_eq!(read(&path).expect("reads"), mine); +} + +#[test] +fn the_file_is_written_where_nothing_existed() { + let dir = a_dir(); + let path = at(&dir.path().join("deeper")); + write(&path, &Config::default()).expect("writes"); + assert!(path.exists()); + assert_eq!(leftovers(&dir), 0); +} + +#[test] +fn two_writers_at_once_leave_one_good_file_and_no_leftovers() { + let dir = a_dir(); + let path = at(dir.path()); + std::thread::scope(|all| { + for turn in 0..8 { + let path = path.clone(); + all.spawn(move || { + let mine = Config { + keeps_days: Some(turn + 1), + ..Config::default() + }; + write(&path, &mine).expect("writes"); + }); + } + }); + let landed = read(&path).expect("reads"); + assert!(matches!(landed.keeps_days, Some(one) if (1..=8).contains(&one))); + assert_eq!(leftovers(&dir), 0); +} + +#[test] +fn a_rename_that_is_refused_for_good_is_not_retried_forever() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::create_dir(&path).expect("makes a directory"); + let why = write(&path, &Config::default()).expect_err("refuses"); + assert!(matches!(why, Error::File(_)), "{why}"); + assert_eq!(leftovers(&dir), 0); +} + +fn leftovers(dir: &tempfile::TempDir) -> usize { + std::fs::read_dir(dir.path()) + .expect("reads the directory") + .filter_map(Result::ok) + .filter(|one| one.file_name().to_string_lossy().ends_with(".new")) + .count() +} + +#[test] +fn a_half_written_file_fills_the_rest_with_the_defaults() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "theme = \"dark\"\n").expect("writes"); + let config = read(&path).expect("reads"); + assert_eq!(config.theme, Theme::Dark); + assert_eq!(config.keeps_days, Config::default().keeps_days); + assert_eq!(config.shortcut, Config::default().shortcut); +} + +#[test] +fn a_file_that_is_not_toml_says_so_instead_of_starting_over() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "theme = = dark").expect("writes"); + let why = read(&path).expect_err("refuses"); + assert!(matches!(why, Error::Malformed(_)), "{why}"); + assert!(why.to_string().contains("TOML"), "{why}"); +} + +#[test] +fn a_directory_where_the_file_should_be_is_not_mistaken_for_an_empty_one() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::create_dir(&path).expect("makes a directory"); + assert!(matches!(read(&path), Err(Error::File(_)))); +} + +#[test] +fn nothing_and_zero_mean_the_same_and_are_written_once() { + let asked = Config { + keeps_days: Some(0), + images_quota_mb: Some(0), + shortcut: " ".into(), + locale: Some(" ".into()), + ..Config::default() + } + .sane(); + assert_eq!(asked.keeps_days, None); + assert_eq!(asked.images_quota_mb, None); + assert_eq!(asked.shortcut, SHORTCUT); + assert_eq!(asked.locale, None); +} + +#[test] +fn what_is_read_is_sane_even_when_the_file_is_not() { + let dir = a_dir(); + let path = at(dir.path()); + std::fs::write(&path, "keeps-days = 0\nimages-quota-mb = 0\n").expect("writes"); + let config = read(&path).expect("reads"); + assert_eq!(config.keeps_days, None); + assert_eq!(config.images_quota_mb, None); +} + +#[test] +fn the_shortcut_loses_the_spaces_around_it() { + let asked = Config { + shortcut: " Ctrl+Shift+V ".into(), + ..Config::default() + } + .sane(); + assert_eq!(asked.shortcut, "Ctrl+Shift+V"); +} + +#[test] +fn the_file_reads_as_a_person_would_write_it() { + let dir = a_dir(); + let path = at(dir.path()); + write(&path, &Config::default()).expect("writes"); + let said = std::fs::read_to_string(&path).expect("reads"); + assert!(said.contains("theme = \"system\""), "{said}"); + assert!(said.contains("keeps-days = 30"), "{said}"); +} + +#[test] +fn nothing_written_as_null_is_taken_for_nothing_at_all() { + let asked: Config = serde_json::from_str( + r#"{"locale":null,"theme":"system","shortcut":"Ctrl+Alt+V","hides-when-left":true,"keeps-days":null,"images-quota-mb":null}"#, + ) + .expect("what the window sends gets in"); + assert_eq!(asked.keeps_days, None); + assert_eq!(asked.images_quota_mb, None); +} + +#[test] +fn keeping_it_forever_survives_the_file() { + let dir = a_dir(); + let path = at(dir.path()); + let mine = Config { + keeps_days: None, + ..Config::default() + }; + write(&path, &mine).expect("writes"); + let said = std::fs::read_to_string(&path).expect("reads"); + assert!(said.contains("keeps-days = 0"), "{said}"); + assert_eq!(read(&path).expect("reads").keeps_days, None); +} + +#[test] +fn the_path_hangs_from_the_folder_it_is_given() { + let where_it_is = at(Path::new("anywhere")); + assert_eq!(where_it_is, Path::new("anywhere").join("config.toml")); +} diff --git a/crates/cp-core/src/capture.rs b/crates/cp-core/src/capture.rs index ed5e238e..97e229e2 100644 --- a/crates/cp-core/src/capture.rs +++ b/crates/cp-core/src/capture.rs @@ -37,103 +37,5 @@ pub fn insisting( } #[cfg(test)] -mod tests { - use super::*; - - fn quick() -> Retry { - Retry { - attempts: 3, - pause: std::time::Duration::from_millis(1), - } - } - - #[test] - fn a_slow_source_that_answers_on_the_second_try_is_kept() { - let mut tries = 0; - let got = insisting( - quick(), - || 7, - || { - tries += 1; - if tries < 2 { - Captured::TooSlow - } else { - Captured::Kept(Item::plain("late, but it got here")) - } - }, - ); - assert_eq!(got, Captured::Kept(Item::plain("late, but it got here"))); - } - - #[test] - fn a_source_that_stays_slow_is_too_slow_in_the_end() { - let mut tries = 0; - let got = insisting( - quick(), - || 7, - || { - tries += 1; - Captured::TooSlow - }, - ); - assert_eq!(got, Captured::TooSlow); - assert_eq!(tries, 3); - } - - #[test] - fn an_empty_clipboard_is_an_answer_not_a_delay() { - let mut tries = 0; - let got = insisting( - quick(), - || 7, - || { - tries += 1; - Captured::Nothing - }, - ); - assert_eq!(got, Captured::Nothing); - assert_eq!(tries, 1); - } - - #[test] - fn a_refusal_is_final_on_the_first_try() { - let mut tries = 0; - let got = insisting( - quick(), - || 7, - || { - tries += 1; - Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) - }, - ); - assert_eq!( - got, - Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) - ); - assert_eq!(tries, 1); - } - - #[test] - fn a_copy_made_while_insisting_supersedes_the_slow_one() { - let count = std::cell::Cell::new(7); - let got = insisting( - quick(), - || count.get(), - || { - count.set(8); - Captured::TooSlow - }, - ); - assert_eq!(got, Captured::Superseded); - } - - #[test] - fn only_what_was_kept_is_an_item() { - let item = Item::plain("hello"); - assert_eq!(Captured::Kept(item.clone()).kept(), Some(item)); - assert_eq!(Captured::Nothing.kept(), None); - assert_eq!(Captured::TooSlow.kept(), None); - assert_eq!(Captured::Superseded.kept(), None); - assert_eq!(Captured::Refused(Refusal::Declined("x")).kept(), None); - } -} +#[path = "capture_test.rs"] +mod tests; diff --git a/crates/cp-core/src/capture_test.rs b/crates/cp-core/src/capture_test.rs new file mode 100644 index 00000000..bbdbbbdc --- /dev/null +++ b/crates/cp-core/src/capture_test.rs @@ -0,0 +1,98 @@ +use super::*; + +fn quick() -> Retry { + Retry { + attempts: 3, + pause: std::time::Duration::from_millis(1), + } +} + +#[test] +fn a_slow_source_that_answers_on_the_second_try_is_kept() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + if tries < 2 { + Captured::TooSlow + } else { + Captured::Kept(Item::plain("late, but it got here")) + } + }, + ); + assert_eq!(got, Captured::Kept(Item::plain("late, but it got here"))); +} + +#[test] +fn a_source_that_stays_slow_is_too_slow_in_the_end() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::TooSlow + }, + ); + assert_eq!(got, Captured::TooSlow); + assert_eq!(tries, 3); +} + +#[test] +fn an_empty_clipboard_is_an_answer_not_a_delay() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::Nothing + }, + ); + assert_eq!(got, Captured::Nothing); + assert_eq!(tries, 1); +} + +#[test] +fn a_refusal_is_final_on_the_first_try() { + let mut tries = 0; + let got = insisting( + quick(), + || 7, + || { + tries += 1; + Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) + }, + ); + assert_eq!( + got, + Captured::Refused(Refusal::Marked("org.nspasteboard.ConcealedType")) + ); + assert_eq!(tries, 1); +} + +#[test] +fn a_copy_made_while_insisting_supersedes_the_slow_one() { + let count = std::cell::Cell::new(7); + let got = insisting( + quick(), + || count.get(), + || { + count.set(8); + Captured::TooSlow + }, + ); + assert_eq!(got, Captured::Superseded); +} + +#[test] +fn only_what_was_kept_is_an_item() { + let item = Item::plain("hello"); + assert_eq!(Captured::Kept(item.clone()).kept(), Some(item)); + assert_eq!(Captured::Nothing.kept(), None); + assert_eq!(Captured::TooSlow.kept(), None); + assert_eq!(Captured::Superseded.kept(), None); + assert_eq!(Captured::Refused(Refusal::Declined("x")).kept(), None); +} diff --git a/crates/cp-core/src/destination.rs b/crates/cp-core/src/destination.rs index 99988cf1..f41e0d85 100644 --- a/crates/cp-core/src/destination.rs +++ b/crates/cp-core/src/destination.rs @@ -40,75 +40,5 @@ impl Tracker { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn we_are_never_our_own_destination() { - let mut tracker = Tracker::new(100); - tracker.saw(100, Some("dev.rgdevment.copypaste")); - assert!(tracker.destination().is_none()); - } - - #[test] - fn hiding_the_panel_does_not_lose_the_target() { - let mut tracker = Tracker::new(100); - tracker.saw(200, Some("com.apple.TextEdit")); - tracker.saw(100, Some("dev.rgdevment.copypaste")); - assert_eq!( - tracker.destination().map(|one| one.pid), - Some(200), - "the destination is still whoever was there before the panel" - ); - } - - #[test] - fn the_newest_foreign_application_wins() { - let mut tracker = Tracker::new(100); - tracker.saw(200, Some("com.apple.TextEdit")); - tracker.saw(300, Some("com.apple.Safari")); - assert_eq!(tracker.destination().map(|one| one.pid), Some(300)); - } - - #[test] - fn two_windows_of_the_same_application_are_told_apart_by_pid() { - let mut tracker = Tracker::new(100); - tracker.saw(200, Some("com.apple.Terminal")); - tracker.saw(201, Some("com.apple.Terminal")); - let destination = tracker.destination().expect("there is a destination"); - assert_eq!( - destination.pid, 201, - "the specific instance, not the bundle" - ); - assert_eq!(destination.bundle_id.as_deref(), Some("com.apple.Terminal")); - } - - #[test] - fn an_application_without_a_bundle_is_still_a_destination() { - let mut tracker = Tracker::new(100); - tracker.saw(200, None); - assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); - } - - #[test] - fn a_closed_target_stops_being_one() { - let mut tracker = Tracker::new(100); - tracker.saw(200, Some("com.apple.TextEdit")); - tracker.gone(200); - assert!(tracker.destination().is_none()); - } - - #[test] - fn another_application_closing_changes_nothing() { - let mut tracker = Tracker::new(100); - tracker.saw(200, Some("com.apple.TextEdit")); - tracker.gone(999); - assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); - } - - #[test] - fn nothing_seen_means_nowhere_to_paste() { - let tracker = Tracker::new(100); - assert!(tracker.destination().is_none()); - } -} +#[path = "destination_test.rs"] +mod tests; diff --git a/crates/cp-core/src/destination_test.rs b/crates/cp-core/src/destination_test.rs new file mode 100644 index 00000000..2b12db4f --- /dev/null +++ b/crates/cp-core/src/destination_test.rs @@ -0,0 +1,70 @@ +use super::*; + +#[test] +fn we_are_never_our_own_destination() { + let mut tracker = Tracker::new(100); + tracker.saw(100, Some("dev.rgdevment.copypaste")); + assert!(tracker.destination().is_none()); +} + +#[test] +fn hiding_the_panel_does_not_lose_the_target() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.saw(100, Some("dev.rgdevment.copypaste")); + assert_eq!( + tracker.destination().map(|one| one.pid), + Some(200), + "the destination is still whoever was there before the panel" + ); +} + +#[test] +fn the_newest_foreign_application_wins() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.saw(300, Some("com.apple.Safari")); + assert_eq!(tracker.destination().map(|one| one.pid), Some(300)); +} + +#[test] +fn two_windows_of_the_same_application_are_told_apart_by_pid() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.Terminal")); + tracker.saw(201, Some("com.apple.Terminal")); + let destination = tracker.destination().expect("there is a destination"); + assert_eq!( + destination.pid, 201, + "the specific instance, not the bundle" + ); + assert_eq!(destination.bundle_id.as_deref(), Some("com.apple.Terminal")); +} + +#[test] +fn an_application_without_a_bundle_is_still_a_destination() { + let mut tracker = Tracker::new(100); + tracker.saw(200, None); + assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); +} + +#[test] +fn a_closed_target_stops_being_one() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.gone(200); + assert!(tracker.destination().is_none()); +} + +#[test] +fn another_application_closing_changes_nothing() { + let mut tracker = Tracker::new(100); + tracker.saw(200, Some("com.apple.TextEdit")); + tracker.gone(999); + assert_eq!(tracker.destination().map(|one| one.pid), Some(200)); +} + +#[test] +fn nothing_seen_means_nowhere_to_paste() { + let tracker = Tracker::new(100); + assert!(tracker.destination().is_none()); +} diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index 45c141da..7efb3ab1 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -195,577 +195,9 @@ pub fn from_image(decoded: &image::DynamicImage) -> Option> { } #[cfg(test)] -mod tests { - use super::*; - - struct Dib { - header_size: u32, - width: i32, - height: i32, - bit_count: u16, - compression: u32, - clr_used: u32, - table: Vec, - pixels: Vec, - } - - impl Dib { - fn rgb32(width: i32, height: i32) -> Self { - Self { - header_size: INFO_HEADER as u32, - width, - height, - bit_count: 32, - compression: 0, - clr_used: 0, - table: Vec::new(), - pixels: [0x40, 0x80, 0xC0, 0xFF].repeat((width * height.abs()) as usize), - } - } - - fn build(&self) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&self.header_size.to_le_bytes()); - out.extend_from_slice(&self.width.to_le_bytes()); - out.extend_from_slice(&self.height.to_le_bytes()); - out.extend_from_slice(&1u16.to_le_bytes()); - out.extend_from_slice(&self.bit_count.to_le_bytes()); - out.extend_from_slice(&self.compression.to_le_bytes()); - out.extend_from_slice(&(self.pixels.len() as u32).to_le_bytes()); - out.extend_from_slice(&2835i32.to_le_bytes()); - out.extend_from_slice(&2835i32.to_le_bytes()); - out.extend_from_slice(&self.clr_used.to_le_bytes()); - out.extend_from_slice(&0u32.to_le_bytes()); - out.resize(self.header_size as usize, 0); - out.extend_from_slice(&self.table); - out.extend_from_slice(&self.pixels); - out - } - } - - #[test] - fn a_plain_dib_puts_the_pixels_right_after_the_header() { - let dib = Dib::rgb32(2, 2).build(); - assert_eq!(pixel_offset(&dib), Some(INFO_HEADER)); - } - - #[test] - fn bitfield_masks_follow_the_classic_header() { - let mut dib = Dib::rgb32(2, 2); - dib.compression = BI_BITFIELDS; - dib.table = vec![0; 12]; - assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 12)); - } - - #[test] - fn bitfield_masks_live_inside_the_newer_headers() { - for size in [108u32, 124] { - let mut dib = Dib::rgb32(2, 2); - dib.header_size = size; - dib.compression = BI_BITFIELDS; - assert_eq!( - pixel_offset(&dib.build()), - Some(size as usize), - "header of {size} bytes" - ); - } - } - - #[test] - fn a_palette_below_nine_bits_shifts_the_pixels() { - let mut dib = Dib::rgb32(4, 1); - dib.bit_count = 8; - dib.pixels = vec![0, 1, 2, 3]; - dib.table = vec![0; 256 * RGBQUAD]; - assert_eq!( - pixel_offset(&dib.build()), - Some(INFO_HEADER + 256 * RGBQUAD), - "without biClrUsed the whole palette is assumed" - ); - } - - #[test] - fn a_declared_palette_size_is_honoured() { - let mut dib = Dib::rgb32(4, 1); - dib.bit_count = 8; - dib.clr_used = 16; - dib.pixels = vec![0, 1, 2, 3]; - dib.table = vec![0; 16 * RGBQUAD]; - assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 16 * RGBQUAD)); - } - - #[test] - fn a_dirty_palette_count_above_eight_bits_is_ignored() { - let mut dib = Dib::rgb32(2, 2); - dib.clr_used = 1_000_000; - assert_eq!( - pixel_offset(&dib.build()), - Some(INFO_HEADER), - "what doesn't fit isn't subtracted" - ); - } - - #[test] - fn a_palette_that_does_fit_above_eight_bits_is_applied() { - let mut dib = Dib::rgb32(2, 2); - dib.clr_used = 2; - dib.table = vec![0; 2 * RGBQUAD]; - assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 2 * RGBQUAD)); - } - - #[test] - fn a_header_that_fills_the_whole_buffer_is_still_a_header() { - let mut dib = Dib::rgb32(1, 1); - dib.pixels = Vec::new(); - let raw = dib.build(); - assert_eq!(raw.len(), INFO_HEADER); - assert_eq!( - header(&raw).map(|head| head.size), - Some(INFO_HEADER as u32), - "measuring exactly what's there isn't measuring too much" - ); - assert_eq!(pixel_offset(&raw), Some(INFO_HEADER)); - } - - #[test] - fn a_palette_one_byte_too_long_is_left_out() { - let mut dib = Dib::rgb32(2, 2); - dib.clr_used = 2; - dib.pixels = vec![0; 5]; - let raw = dib.build(); - assert_eq!(raw.len(), INFO_HEADER + 5); - assert_eq!( - pixel_offset(&raw), - Some(INFO_HEADER), - "eight bytes of palette don't fit in five" - ); - } - - #[test] - fn the_masks_count_towards_whether_the_palette_fits() { - let mut dib = Dib::rgb32(2, 2); - dib.compression = BI_BITFIELDS; - dib.clr_used = 3; - dib.table = vec![0; 12]; - dib.pixels = vec![0; 8]; - let raw = dib.build(); - assert_eq!(raw.len(), INFO_HEADER + 12 + 8); - assert_eq!( - pixel_offset(&raw), - Some(INFO_HEADER + 12), - "twelve of masks plus twelve of palette don't fit in twenty" - ); - } - - #[test] - fn nonsense_is_not_a_bitmap() { - assert_eq!(header(&[]), None); - assert_eq!(header(&[0; 8]), None, "doesn't even reach the header"); - assert_eq!( - header(&[0; 40]), - None, - "a header that claims to measure zero" - ); - let mut lying = Dib::rgb32(2, 2).build(); - lying[0..4].copy_from_slice(&9999u32.to_le_bytes()); - assert_eq!( - header(&lying), - None, - "claims to measure more than the whole buffer" - ); - assert_eq!(as_bmp(&[]), None); - assert_eq!(to_png(&[]), None); - } - - #[test] - fn the_file_header_points_at_the_pixels() { - let dib = Dib::rgb32(2, 2).build(); - let bmp = as_bmp(&dib).expect("bmp"); - assert_eq!(&bmp[0..2], b"BM"); - assert_eq!( - u32::from_le_bytes([bmp[2], bmp[3], bmp[4], bmp[5]]) as usize, - FILE_HEADER + dib.len(), - "the declared size is that of the whole file" - ); - assert_eq!( - u32::from_le_bytes([bmp[10], bmp[11], bmp[12], bmp[13]]) as usize, - FILE_HEADER + INFO_HEADER - ); - assert_eq!(&bmp[FILE_HEADER..], &dib[..], "the DIB travels intact"); - } - - #[test] - fn an_alpha_channel_nobody_wrote_is_not_transparency() { - let mut dib = Dib::rgb32(2, 2); - dib.pixels = [0x40, 0x80, 0xC0, 0x00].repeat(4); - assert_eq!(alpha(&dib.build()), Alpha::Opaque); - } - - #[test] - fn an_alpha_channel_fully_opaque_is_opaque() { - let dib = Dib::rgb32(2, 2).build(); - assert_eq!(alpha(&dib), Alpha::Opaque); - } - - #[test] - fn a_mixed_alpha_channel_is_real_transparency() { - let mut dib = Dib::rgb32(2, 2); - dib.pixels = vec![ - 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, 0xC0, 0x00, 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, - 0xC0, 0xFF, - ]; - assert_eq!( - alpha(&dib.build()), - Alpha::Real, - "some at zero and others opaque is a cutout with edges" - ); - } - - #[test] - fn a_partial_alpha_value_is_real_transparency() { - let mut dib = Dib::rgb32(2, 2); - dib.pixels = [0x40, 0x80, 0xC0, 0x7F].repeat(4); - assert_eq!(alpha(&dib.build()), Alpha::Real); - } - - #[test] - fn a_header_promising_more_than_the_buffer_holds_has_no_alpha() { - let mut dib = Dib::rgb32(1, 1); - dib.compression = BI_BITFIELDS; - dib.pixels = Vec::new(); - let raw = dib.build(); - assert_eq!(raw.len(), INFO_HEADER); - assert_eq!(pixel_offset(&raw), None, "the masks don't fit"); - assert_eq!(alpha(&raw), Alpha::Absent); - assert_eq!(to_png(&raw), None); - } - - #[test] - fn a_synthesised_v5_declaring_no_alpha_mask_has_no_alpha() { - let mut dib = Dib::rgb32(2, 2); - dib.header_size = 124; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); - let raw = dib.build(); - assert_eq!(u32::from_le_bytes([raw[52], raw[53], raw[54], raw[55]]), 0); - assert_eq!( - alpha(&raw), - Alpha::Absent, - "the fourth byte looks like alpha, but the header says it isn't" - ); - } - - #[test] - fn a_synthesised_v5_does_not_become_half_transparent() { - let mut dib = Dib::rgb32(4, 4); - dib.header_size = 124; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(16); - let png = to_png(&dib.build()).expect("png"); - let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); - assert!( - back.pixels().all(|pixel| pixel[3] == 0xFF), - "a transparency nobody declared isn't recorded" - ); - } - - #[test] - fn the_shortest_header_that_declares_alpha_is_fifty_six_bytes() { - let mut dib = Dib::rgb32(2, 2); - dib.header_size = 56; - dib.compression = BI_BITFIELDS; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); - let mut raw = dib.build(); - raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); - raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); - raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); - raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); - assert_eq!(pixel_offset(&raw), Some(56), "the masks go inside"); - assert_eq!( - alpha(&raw), - Alpha::Real, - "it declares the alpha mask, so it has to be read" - ); - } - - #[test] - fn a_fifty_six_byte_header_with_no_mask_has_no_alpha() { - let mut dib = Dib::rgb32(2, 2); - dib.header_size = 56; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); - assert_eq!(alpha(&dib.build()), Alpha::Absent); - } - - #[test] - fn bytes_past_the_pixel_array_do_not_vote_on_the_alpha() { - let mut dib = Dib::rgb32(4, 4); - dib.header_size = 124; - dib.compression = BI_BITFIELDS; - dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); - let mut raw = dib.build(); - raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); - raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); - raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); - raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); - assert_eq!(alpha(&raw), Alpha::Opaque); - - raw.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); - assert_eq!( - alpha(&raw), - Alpha::Opaque, - "four trailing bytes used to make the capture look transparent" - ); - let back = image::load_from_memory(&to_png(&raw).expect("png")) - .expect("rereads") - .to_rgba8(); - assert!(back.pixels().all(|pixel| pixel[3] == 0xFF)); - } - - #[test] - fn a_declared_mask_turns_the_same_pixels_into_real_alpha() { - let mut dib = Dib::rgb32(2, 2); - dib.header_size = 124; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); - let mut raw = dib.build(); - raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); - assert_eq!(alpha(&raw), Alpha::Real); - } - - #[test] - fn the_classic_header_has_no_alpha_mask_to_declare() { - let mut dib = Dib::rgb32(2, 2); - dib.compression = BI_BITFIELDS; - dib.table = vec![0; 12]; - dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); - assert_eq!(alpha(&dib.build()), Alpha::Real); - } - - #[test] - fn below_thirty_two_bits_there_is_no_alpha_to_read() { - let mut dib = Dib::rgb32(2, 2); - dib.bit_count = 24; - dib.pixels = [0x40, 0x80, 0xC0].repeat(4); - assert_eq!(alpha(&dib.build()), Alpha::Absent); - } - - #[test] - fn a_bitmap_becomes_a_fraction_of_its_size_as_png() { - let mut dib = Dib::rgb32(200, 200); - dib.pixels = [0x20, 0x60, 0xA0, 0xFF].repeat(200 * 200); - let raw = dib.build(); - let png = to_png(&raw).expect("png"); - assert!( - png.len() * 20 < raw.len(), - "{} bytes of DIB against {} of PNG", - raw.len(), - png.len() - ); - } - - #[test] - fn the_size_limit_falls_between_the_last_accepted_byte_and_the_first_refused() { - assert!(!too_large(LARGEST_BITMAP - 1)); - assert!(!too_large(LARGEST_BITMAP)); - assert!(too_large(LARGEST_BITMAP + 1)); - assert!(too_large(usize::MAX)); - assert!(!too_large(0)); - } - - #[test] - fn a_bitmap_too_large_to_be_a_screen_is_refused_before_it_is_copied() { - let mut dib = Dib::rgb32(1, 1); - dib.pixels = vec![0; LARGEST_BITMAP + 1 - INFO_HEADER]; - let raw = dib.build(); - assert!(too_large(raw.len())); - assert_eq!(to_png(&raw), None); - } - - #[test] - fn a_png_becomes_a_bitmap_the_clipboard_understands() { - let dib = Dib::rgb32(4, 4).build(); - let png = to_png(&dib).expect("png"); - let back = from_png(&png).expect("dib"); - let head = header(&back).expect("header"); - assert_eq!((head.width, head.height.abs()), (4, 4)); - assert!(pixel_offset(&back).is_some()); - } - - #[test] - fn the_round_trip_keeps_the_colours_where_they_were() { - let mut dib = Dib::rgb32(2, 2); - dib.pixels = vec![ - 0x00, 0x00, 0xFF, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, - 0xFF, 0xFF, - ]; - let once = image::load_from_memory(&to_png(&dib.build()).expect("png")) - .expect("rereads") - .to_rgba8(); - let twice = image::load_from_memory( - &to_png(&from_png(&to_png(&dib.build()).expect("png")).expect("dib")).expect("png"), - ) - .expect("rereads") - .to_rgba8(); - assert_eq!(once.as_raw(), twice.as_raw()); - } - - #[test] - fn what_is_not_a_png_is_not_a_bitmap_either() { - assert_eq!(from_png(&[]), None); - assert_eq!(from_png(b"this is not a png"), None); - assert_eq!(from_png(&vec![0u8; LARGEST_BITMAP + 1]), None); - } - - #[test] - fn a_jpeg_becomes_a_bitmap_too_and_a_png_is_not_a_jpeg() { - let dib = Dib::rgb32(4, 4).build(); - let png = to_png(&dib).expect("png"); - let mut jpeg = Vec::new(); - image::load_from_memory(&png) - .expect("rereads") - .to_rgb8() - .write_to( - &mut std::io::Cursor::new(&mut jpeg), - image::ImageFormat::Jpeg, - ) - .expect("jpeg"); - let back = from_jpeg(&jpeg).expect("dib"); - let head = header(&back).expect("header"); - assert_eq!((head.width, head.height.abs()), (4, 4)); - assert_eq!( - head.bit_count, 24, - "a JPEG carries no alpha and neither does the DIB" - ); - assert_eq!( - from_jpeg(&png), - None, - "a PNG doesn't sneak in through the JPEG door" - ); - assert_eq!(from_jpeg(&[]), None); - assert_eq!(from_jpeg(&vec![0u8; LARGEST_BITMAP + 1]), None); - } - - #[test] - fn a_decoded_image_with_no_pixels_is_no_bitmap() { - assert_eq!(from_image(&image::DynamicImage::new_rgb8(0, 0)), None); - assert_eq!(from_image(&image::DynamicImage::new_rgba8(3, 0)), None); - let opaque = from_image(&image::DynamicImage::new_rgb8(1, 1)).expect("dib"); - assert_eq!(header(&opaque).expect("header").bit_count, 24); - let translucent = from_image(&image::DynamicImage::new_rgba8(1, 1)).expect("dib"); - assert_eq!(header(&translucent).expect("header").bit_count, 32); - } - - #[test] - fn a_png_from_a_dib_is_a_png() { - let dib = Dib::rgb32(4, 4).build(); - let png = to_png(&dib).expect("png"); - assert_eq!(&png[1..4], b"PNG"); - let back = image::load_from_memory(&png).expect("rereads"); - assert_eq!((back.width(), back.height()), (4, 4)); - } - - #[test] - fn a_capture_with_an_unwritten_alpha_does_not_become_invisible() { - let mut dib = Dib::rgb32(4, 4); - dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); - let png = to_png(&dib.build()).expect("png"); - let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); - assert!( - back.pixels().all(|pixel| pixel[3] == 0xFF), - "the image has to be visible" - ); - } - - #[test] - fn a_declared_alpha_channel_left_at_zero_is_not_an_invisible_capture() { - let mut dib = Dib::rgb32(4, 4); - dib.header_size = 124; - dib.compression = BI_BITFIELDS; - dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); - let mut raw = dib.build(); - raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); - raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); - raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); - raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); - assert_eq!(alpha(&raw), Alpha::Opaque, "all zero is not transparency"); - let png = to_png(&raw).expect("png"); - let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); - assert!( - back.pixels().all(|pixel| pixel[3] == 0xFF), - "the capture has to be visible" - ); - } - - #[test] - fn real_transparency_survives_the_trip() { - let mut dib = Dib::rgb32(2, 2); - dib.compression = BI_BITFIELDS; - dib.header_size = 124; - dib.pixels = vec![ - 0x20, 0x60, 0xA0, 0x00, 0x20, 0x60, 0xA0, 0x80, 0x20, 0x60, 0xA0, 0xC0, 0x20, 0x60, - 0xA0, 0xFF, - ]; - let mut raw = dib.build(); - raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); - raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); - raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); - raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); - assert_eq!(alpha(&raw), Alpha::Real); - let png = to_png(&raw).expect("png"); - let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); - let seen: Vec = back.pixels().map(|pixel| pixel[3]).collect(); - assert!( - seen.contains(&0x00) && seen.contains(&0xFF), - "the four alpha levels arrived as {seen:?}" - ); - } - - #[test] - fn a_truncated_bitmap_is_refused_not_panicked_on() { - let dib = Dib::rgb32(100, 100).build(); - for cut in [0, 1, 20, 39, 40, 41, 100, 500] { - let short = &dib[..cut.min(dib.len())]; - let _ = header(short); - let _ = pixel_offset(short); - let _ = alpha(short); - let _ = as_bmp(short); - let _ = to_png(short); - } - } -} +#[path = "dib_test.rs"] +mod tests; #[cfg(test)] -mod properties { - use super::*; - use proptest::prelude::*; - - proptest! { - #[test] - fn no_pile_of_bytes_can_bring_the_process_down( - bytes in prop::collection::vec(any::(), 0..600), - ) { - let _ = header(&bytes); - let _ = pixel_offset(&bytes); - let _ = alpha(&bytes); - let _ = as_bmp(&bytes); - let _ = to_png(&bytes); - } - - #[test] - fn the_pixels_always_start_inside_the_buffer( - bytes in prop::collection::vec(any::(), 0..600), - ) { - if let Some(at) = pixel_offset(&bytes) { - prop_assert!(at <= bytes.len()); - prop_assert!(at >= super::INFO_HEADER); - } - } - - #[test] - fn the_bmp_is_the_dib_with_a_header_in_front( - bytes in prop::collection::vec(any::(), 0..600), - ) { - if let Some(bmp) = as_bmp(&bytes) { - prop_assert_eq!(bmp.len(), bytes.len() + super::FILE_HEADER); - prop_assert_eq!(&bmp[super::FILE_HEADER..], &bytes[..]); - } - } - } -} +#[path = "dib_properties.rs"] +mod properties; diff --git a/crates/cp-core/src/dib_properties.rs b/crates/cp-core/src/dib_properties.rs new file mode 100644 index 00000000..d1dd2a8e --- /dev/null +++ b/crates/cp-core/src/dib_properties.rs @@ -0,0 +1,35 @@ +use super::*; +use proptest::prelude::*; + +proptest! { + #[test] + fn no_pile_of_bytes_can_bring_the_process_down( + bytes in prop::collection::vec(any::(), 0..600), + ) { + let _ = header(&bytes); + let _ = pixel_offset(&bytes); + let _ = alpha(&bytes); + let _ = as_bmp(&bytes); + let _ = to_png(&bytes); + } + + #[test] + fn the_pixels_always_start_inside_the_buffer( + bytes in prop::collection::vec(any::(), 0..600), + ) { + if let Some(at) = pixel_offset(&bytes) { + prop_assert!(at <= bytes.len()); + prop_assert!(at >= super::INFO_HEADER); + } + } + + #[test] + fn the_bmp_is_the_dib_with_a_header_in_front( + bytes in prop::collection::vec(any::(), 0..600), + ) { + if let Some(bmp) = as_bmp(&bytes) { + prop_assert_eq!(bmp.len(), bytes.len() + super::FILE_HEADER); + prop_assert_eq!(&bmp[super::FILE_HEADER..], &bytes[..]); + } + } +} diff --git a/crates/cp-core/src/dib_test.rs b/crates/cp-core/src/dib_test.rs new file mode 100644 index 00000000..bc3e94cf --- /dev/null +++ b/crates/cp-core/src/dib_test.rs @@ -0,0 +1,533 @@ +use super::*; + +struct Dib { + header_size: u32, + width: i32, + height: i32, + bit_count: u16, + compression: u32, + clr_used: u32, + table: Vec, + pixels: Vec, +} + +impl Dib { + fn rgb32(width: i32, height: i32) -> Self { + Self { + header_size: INFO_HEADER as u32, + width, + height, + bit_count: 32, + compression: 0, + clr_used: 0, + table: Vec::new(), + pixels: [0x40, 0x80, 0xC0, 0xFF].repeat((width * height.abs()) as usize), + } + } + + fn build(&self) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&self.header_size.to_le_bytes()); + out.extend_from_slice(&self.width.to_le_bytes()); + out.extend_from_slice(&self.height.to_le_bytes()); + out.extend_from_slice(&1u16.to_le_bytes()); + out.extend_from_slice(&self.bit_count.to_le_bytes()); + out.extend_from_slice(&self.compression.to_le_bytes()); + out.extend_from_slice(&(self.pixels.len() as u32).to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&2835i32.to_le_bytes()); + out.extend_from_slice(&self.clr_used.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); + out.resize(self.header_size as usize, 0); + out.extend_from_slice(&self.table); + out.extend_from_slice(&self.pixels); + out + } +} + +#[test] +fn a_plain_dib_puts_the_pixels_right_after_the_header() { + let dib = Dib::rgb32(2, 2).build(); + assert_eq!(pixel_offset(&dib), Some(INFO_HEADER)); +} + +#[test] +fn bitfield_masks_follow_the_classic_header() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.table = vec![0; 12]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 12)); +} + +#[test] +fn bitfield_masks_live_inside_the_newer_headers() { + for size in [108u32, 124] { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = size; + dib.compression = BI_BITFIELDS; + assert_eq!( + pixel_offset(&dib.build()), + Some(size as usize), + "header of {size} bytes" + ); + } +} + +#[test] +fn a_palette_below_nine_bits_shifts_the_pixels() { + let mut dib = Dib::rgb32(4, 1); + dib.bit_count = 8; + dib.pixels = vec![0, 1, 2, 3]; + dib.table = vec![0; 256 * RGBQUAD]; + assert_eq!( + pixel_offset(&dib.build()), + Some(INFO_HEADER + 256 * RGBQUAD), + "without biClrUsed the whole palette is assumed" + ); +} + +#[test] +fn a_declared_palette_size_is_honoured() { + let mut dib = Dib::rgb32(4, 1); + dib.bit_count = 8; + dib.clr_used = 16; + dib.pixels = vec![0, 1, 2, 3]; + dib.table = vec![0; 16 * RGBQUAD]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 16 * RGBQUAD)); +} + +#[test] +fn a_dirty_palette_count_above_eight_bits_is_ignored() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 1_000_000; + assert_eq!( + pixel_offset(&dib.build()), + Some(INFO_HEADER), + "what doesn't fit isn't subtracted" + ); +} + +#[test] +fn a_palette_that_does_fit_above_eight_bits_is_applied() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 2; + dib.table = vec![0; 2 * RGBQUAD]; + assert_eq!(pixel_offset(&dib.build()), Some(INFO_HEADER + 2 * RGBQUAD)); +} + +#[test] +fn a_header_that_fills_the_whole_buffer_is_still_a_header() { + let mut dib = Dib::rgb32(1, 1); + dib.pixels = Vec::new(); + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER); + assert_eq!( + header(&raw).map(|head| head.size), + Some(INFO_HEADER as u32), + "measuring exactly what's there isn't measuring too much" + ); + assert_eq!(pixel_offset(&raw), Some(INFO_HEADER)); +} + +#[test] +fn a_palette_one_byte_too_long_is_left_out() { + let mut dib = Dib::rgb32(2, 2); + dib.clr_used = 2; + dib.pixels = vec![0; 5]; + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER + 5); + assert_eq!( + pixel_offset(&raw), + Some(INFO_HEADER), + "eight bytes of palette don't fit in five" + ); +} + +#[test] +fn the_masks_count_towards_whether_the_palette_fits() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.clr_used = 3; + dib.table = vec![0; 12]; + dib.pixels = vec![0; 8]; + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER + 12 + 8); + assert_eq!( + pixel_offset(&raw), + Some(INFO_HEADER + 12), + "twelve of masks plus twelve of palette don't fit in twenty" + ); +} + +#[test] +fn nonsense_is_not_a_bitmap() { + assert_eq!(header(&[]), None); + assert_eq!(header(&[0; 8]), None, "doesn't even reach the header"); + assert_eq!( + header(&[0; 40]), + None, + "a header that claims to measure zero" + ); + let mut lying = Dib::rgb32(2, 2).build(); + lying[0..4].copy_from_slice(&9999u32.to_le_bytes()); + assert_eq!( + header(&lying), + None, + "claims to measure more than the whole buffer" + ); + assert_eq!(as_bmp(&[]), None); + assert_eq!(to_png(&[]), None); +} + +#[test] +fn the_file_header_points_at_the_pixels() { + let dib = Dib::rgb32(2, 2).build(); + let bmp = as_bmp(&dib).expect("bmp"); + assert_eq!(&bmp[0..2], b"BM"); + assert_eq!( + u32::from_le_bytes([bmp[2], bmp[3], bmp[4], bmp[5]]) as usize, + FILE_HEADER + dib.len(), + "the declared size is that of the whole file" + ); + assert_eq!( + u32::from_le_bytes([bmp[10], bmp[11], bmp[12], bmp[13]]) as usize, + FILE_HEADER + INFO_HEADER + ); + assert_eq!(&bmp[FILE_HEADER..], &dib[..], "the DIB travels intact"); +} + +#[test] +fn an_alpha_channel_nobody_wrote_is_not_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = [0x40, 0x80, 0xC0, 0x00].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Opaque); +} + +#[test] +fn an_alpha_channel_fully_opaque_is_opaque() { + let dib = Dib::rgb32(2, 2).build(); + assert_eq!(alpha(&dib), Alpha::Opaque); +} + +#[test] +fn a_mixed_alpha_channel_is_real_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = vec![ + 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, 0xC0, 0x00, 0x40, 0x80, 0xC0, 0xFF, 0x40, 0x80, 0xC0, + 0xFF, + ]; + assert_eq!( + alpha(&dib.build()), + Alpha::Real, + "some at zero and others opaque is a cutout with edges" + ); +} + +#[test] +fn a_partial_alpha_value_is_real_transparency() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = [0x40, 0x80, 0xC0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Real); +} + +#[test] +fn a_header_promising_more_than_the_buffer_holds_has_no_alpha() { + let mut dib = Dib::rgb32(1, 1); + dib.compression = BI_BITFIELDS; + dib.pixels = Vec::new(); + let raw = dib.build(); + assert_eq!(raw.len(), INFO_HEADER); + assert_eq!(pixel_offset(&raw), None, "the masks don't fit"); + assert_eq!(alpha(&raw), Alpha::Absent); + assert_eq!(to_png(&raw), None); +} + +#[test] +fn a_synthesised_v5_declaring_no_alpha_mask_has_no_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let raw = dib.build(); + assert_eq!(u32::from_le_bytes([raw[52], raw[53], raw[54], raw[55]]), 0); + assert_eq!( + alpha(&raw), + Alpha::Absent, + "the fourth byte looks like alpha, but the header says it isn't" + ); +} + +#[test] +fn a_synthesised_v5_does_not_become_half_transparent() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(16); + let png = to_png(&dib.build()).expect("png"); + let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "a transparency nobody declared isn't recorded" + ); +} + +#[test] +fn the_shortest_header_that_declares_alpha_is_fifty_six_bytes() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 56; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(pixel_offset(&raw), Some(56), "the masks go inside"); + assert_eq!( + alpha(&raw), + Alpha::Real, + "it declares the alpha mask, so it has to be read" + ); +} + +#[test] +fn a_fifty_six_byte_header_with_no_mask_has_no_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 56; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Absent); +} + +#[test] +fn bytes_past_the_pixel_array_do_not_vote_on_the_alpha() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Opaque); + + raw.extend_from_slice(&[0x00, 0x00, 0x00, 0xFF]); + assert_eq!( + alpha(&raw), + Alpha::Opaque, + "four trailing bytes used to make the capture look transparent" + ); + let back = image::load_from_memory(&to_png(&raw).expect("png")) + .expect("rereads") + .to_rgba8(); + assert!(back.pixels().all(|pixel| pixel[3] == 0xFF)); +} + +#[test] +fn a_declared_mask_turns_the_same_pixels_into_real_alpha() { + let mut dib = Dib::rgb32(2, 2); + dib.header_size = 124; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + let mut raw = dib.build(); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Real); +} + +#[test] +fn the_classic_header_has_no_alpha_mask_to_declare() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.table = vec![0; 12]; + dib.pixels = [0x20, 0x60, 0xA0, 0x7F].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Real); +} + +#[test] +fn below_thirty_two_bits_there_is_no_alpha_to_read() { + let mut dib = Dib::rgb32(2, 2); + dib.bit_count = 24; + dib.pixels = [0x40, 0x80, 0xC0].repeat(4); + assert_eq!(alpha(&dib.build()), Alpha::Absent); +} + +#[test] +fn a_bitmap_becomes_a_fraction_of_its_size_as_png() { + let mut dib = Dib::rgb32(200, 200); + dib.pixels = [0x20, 0x60, 0xA0, 0xFF].repeat(200 * 200); + let raw = dib.build(); + let png = to_png(&raw).expect("png"); + assert!( + png.len() * 20 < raw.len(), + "{} bytes of DIB against {} of PNG", + raw.len(), + png.len() + ); +} + +#[test] +fn the_size_limit_falls_between_the_last_accepted_byte_and_the_first_refused() { + assert!(!too_large(LARGEST_BITMAP - 1)); + assert!(!too_large(LARGEST_BITMAP)); + assert!(too_large(LARGEST_BITMAP + 1)); + assert!(too_large(usize::MAX)); + assert!(!too_large(0)); +} + +#[test] +fn a_bitmap_too_large_to_be_a_screen_is_refused_before_it_is_copied() { + let mut dib = Dib::rgb32(1, 1); + dib.pixels = vec![0; LARGEST_BITMAP + 1 - INFO_HEADER]; + let raw = dib.build(); + assert!(too_large(raw.len())); + assert_eq!(to_png(&raw), None); +} + +#[test] +fn a_png_becomes_a_bitmap_the_clipboard_understands() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + let back = from_png(&png).expect("dib"); + let head = header(&back).expect("header"); + assert_eq!((head.width, head.height.abs()), (4, 4)); + assert!(pixel_offset(&back).is_some()); +} + +#[test] +fn the_round_trip_keeps_the_colours_where_they_were() { + let mut dib = Dib::rgb32(2, 2); + dib.pixels = vec![ + 0x00, 0x00, 0xFF, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, + ]; + let once = image::load_from_memory(&to_png(&dib.build()).expect("png")) + .expect("rereads") + .to_rgba8(); + let twice = image::load_from_memory( + &to_png(&from_png(&to_png(&dib.build()).expect("png")).expect("dib")).expect("png"), + ) + .expect("rereads") + .to_rgba8(); + assert_eq!(once.as_raw(), twice.as_raw()); +} + +#[test] +fn what_is_not_a_png_is_not_a_bitmap_either() { + assert_eq!(from_png(&[]), None); + assert_eq!(from_png(b"this is not a png"), None); + assert_eq!(from_png(&vec![0u8; LARGEST_BITMAP + 1]), None); +} + +#[test] +fn a_jpeg_becomes_a_bitmap_too_and_a_png_is_not_a_jpeg() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + let mut jpeg = Vec::new(); + image::load_from_memory(&png) + .expect("rereads") + .to_rgb8() + .write_to( + &mut std::io::Cursor::new(&mut jpeg), + image::ImageFormat::Jpeg, + ) + .expect("jpeg"); + let back = from_jpeg(&jpeg).expect("dib"); + let head = header(&back).expect("header"); + assert_eq!((head.width, head.height.abs()), (4, 4)); + assert_eq!( + head.bit_count, 24, + "a JPEG carries no alpha and neither does the DIB" + ); + assert_eq!( + from_jpeg(&png), + None, + "a PNG doesn't sneak in through the JPEG door" + ); + assert_eq!(from_jpeg(&[]), None); + assert_eq!(from_jpeg(&vec![0u8; LARGEST_BITMAP + 1]), None); +} + +#[test] +fn a_decoded_image_with_no_pixels_is_no_bitmap() { + assert_eq!(from_image(&image::DynamicImage::new_rgb8(0, 0)), None); + assert_eq!(from_image(&image::DynamicImage::new_rgba8(3, 0)), None); + let opaque = from_image(&image::DynamicImage::new_rgb8(1, 1)).expect("dib"); + assert_eq!(header(&opaque).expect("header").bit_count, 24); + let translucent = from_image(&image::DynamicImage::new_rgba8(1, 1)).expect("dib"); + assert_eq!(header(&translucent).expect("header").bit_count, 32); +} + +#[test] +fn a_png_from_a_dib_is_a_png() { + let dib = Dib::rgb32(4, 4).build(); + let png = to_png(&dib).expect("png"); + assert_eq!(&png[1..4], b"PNG"); + let back = image::load_from_memory(&png).expect("rereads"); + assert_eq!((back.width(), back.height()), (4, 4)); +} + +#[test] +fn a_capture_with_an_unwritten_alpha_does_not_become_invisible() { + let mut dib = Dib::rgb32(4, 4); + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let png = to_png(&dib.build()).expect("png"); + let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "the image has to be visible" + ); +} + +#[test] +fn a_declared_alpha_channel_left_at_zero_is_not_an_invisible_capture() { + let mut dib = Dib::rgb32(4, 4); + dib.header_size = 124; + dib.compression = BI_BITFIELDS; + dib.pixels = [0x20, 0x60, 0xA0, 0x00].repeat(16); + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Opaque, "all zero is not transparency"); + let png = to_png(&raw).expect("png"); + let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); + assert!( + back.pixels().all(|pixel| pixel[3] == 0xFF), + "the capture has to be visible" + ); +} + +#[test] +fn real_transparency_survives_the_trip() { + let mut dib = Dib::rgb32(2, 2); + dib.compression = BI_BITFIELDS; + dib.header_size = 124; + dib.pixels = vec![ + 0x20, 0x60, 0xA0, 0x00, 0x20, 0x60, 0xA0, 0x80, 0x20, 0x60, 0xA0, 0xC0, 0x20, 0x60, 0xA0, + 0xFF, + ]; + let mut raw = dib.build(); + raw[40..44].copy_from_slice(&0x00FF_0000u32.to_le_bytes()); + raw[44..48].copy_from_slice(&0x0000_FF00u32.to_le_bytes()); + raw[48..52].copy_from_slice(&0x0000_00FFu32.to_le_bytes()); + raw[52..56].copy_from_slice(&0xFF00_0000u32.to_le_bytes()); + assert_eq!(alpha(&raw), Alpha::Real); + let png = to_png(&raw).expect("png"); + let back = image::load_from_memory(&png).expect("rereads").to_rgba8(); + let seen: Vec = back.pixels().map(|pixel| pixel[3]).collect(); + assert!( + seen.contains(&0x00) && seen.contains(&0xFF), + "the four alpha levels arrived as {seen:?}" + ); +} + +#[test] +fn a_truncated_bitmap_is_refused_not_panicked_on() { + let dib = Dib::rgb32(100, 100).build(); + for cut in [0, 1, 20, 39, 40, 41, 100, 500] { + let short = &dib[..cut.min(dib.len())]; + let _ = header(short); + let _ = pixel_offset(short); + let _ = alpha(short); + let _ = as_bmp(short); + let _ = to_png(short); + } +} diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index c355111b..2b664f21 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -130,455 +130,13 @@ impl Catalog { } #[cfg(test)] -mod tests { - use super::*; - - pub(super) const PROBE: Catalog = Catalog { - hangs: &["hangs/forever"], - wasteful: &["huge/icon"], - wanted: &[ - "plain/text", - "rich/text", - "cheap/image", - "costly/image", - "one/file", - ], - aliases: &[("old/text", "plain/text")], - concealed: &["secret/marker"], - denied_when_zero: &["may/record"], - opaque_prefixes: &["dyn."], - text: &["plain/text", "rich/text"], - files: &["one/file"], - images_by_preference: &["cheap/image", "costly/image"], - equivalents: &[&["cheap/markup", "costly/markup"]], - embeddable: &["embedded/document"], - page_archives: &["whole/page"], - }; - - const NOTHING: Catalog = Catalog { - hangs: &[], - wasteful: &[], - wanted: &[], - aliases: &[], - concealed: &[], - denied_when_zero: &[], - opaque_prefixes: &[], - text: &[], - files: &[], - images_by_preference: &[], - equivalents: &[], - embeddable: &[], - page_archives: &[], - }; - - #[test] - fn an_empty_catalogue_notes_everything_and_promises_nothing() { - assert_eq!(NOTHING.decide("any/thing"), Take::Presence); - assert_eq!(NOTHING.decide(""), Take::Presence); - assert_eq!(NOTHING.classify(&["any/thing"]), None); - assert_eq!(NOTHING.preferred_image(&["any/thing"]), None); - assert_eq!(NOTHING.refusal(&["any/thing"]), None); - assert_eq!(NOTHING.declines("any/thing", &[0, 0, 0, 0]), None); - assert!(!NOTHING.costlier_twin("any/thing", &["other/thing"])); - assert_eq!(NOTHING.canonical("x"), "x"); - } - - #[test] - fn nothing_offered_at_all_is_not_an_item() { - assert_eq!(PROBE.classify(&[]), None); - assert_eq!(PROBE.preferred_image(&[]), None); - assert_eq!(PROBE.refusal(&[]), None); - } - - #[test] - fn an_empty_type_name_is_just_unknown() { - assert_eq!(PROBE.decide(""), Take::Presence); - assert_eq!(PROBE.canonical(""), ""); - } - - #[test] - fn what_hangs_is_never_asked_for() { - assert_eq!(PROBE.decide("hangs/forever"), Take::Never); - } - - #[test] - fn the_expensive_and_the_unknown_are_only_noted() { - assert_eq!(PROBE.decide("huge/icon"), Take::Presence); - assert_eq!(PROBE.decide("dyn.abc123"), Take::Presence); - assert_eq!(PROBE.decide("who/knows"), Take::Presence); - } - - #[test] - fn wasteful_beats_wanted_when_a_type_is_in_both() { - const GREEDY: Catalog = Catalog { - hangs: &[], - wasteful: &["costly/image"], - wanted: &["costly/image", "plain/text"], - aliases: &[], - concealed: &[], - denied_when_zero: &[], - opaque_prefixes: &[], - text: &["plain/text"], - files: &[], - images_by_preference: &["costly/image"], - equivalents: &[], - embeddable: &[], - page_archives: &[], - }; - assert_eq!( - GREEDY.decide("costly/image"), - Take::Presence, - "being on the wanted list doesn't save a type that wastes" - ); - assert_eq!(GREEDY.decide("plain/text"), Take::Payload); - } - - #[test] - fn a_legacy_name_is_the_modern_one() { - assert_eq!(PROBE.canonical("old/text"), "plain/text"); - assert_eq!(PROBE.decide("old/text"), Take::Payload); - } - - #[test] - fn the_cheapest_representation_wins() { - assert_eq!( - PROBE.preferred_image(&["costly/image", "cheap/image"]), - Some("cheap/image") - ); - assert_eq!( - PROBE.preferred_image(&["costly/image"]), - Some("costly/image") - ); - assert_eq!(PROBE.preferred_image(&["plain/text"]), None); - } - - #[test] - fn a_secret_is_recognised_by_the_type_alone() { - assert_eq!( - PROBE.refusal(&["plain/text", "secret/marker"]), - Some(Refusal::Marked("secret/marker")) - ); - assert_eq!(PROBE.refusal(&["plain/text"]), None); - } - - #[test] - fn classifying_never_discards_the_rest() { - let mixed = ["plain/text", "cheap/image", "one/file"]; - assert_eq!(PROBE.classify(&mixed), Some(Family::Files)); - let kept: Vec<&str> = mixed - .iter() - .filter(|id| PROBE.decide(id) == Take::Payload) - .copied() - .collect(); - assert_eq!( - kept.len(), - 3, - "classifying isn't picking one and discarding the rest" - ); - } - - #[test] - fn an_image_offered_with_its_text_is_still_an_image() { - assert_eq!( - PROBE.classify(&["plain/text", "cheap/image"]), - Some(Family::Image) - ); - } - - #[test] - fn a_copied_file_keeps_its_path_and_notes_its_icon() { - let files = Some(Family::Files); - assert_eq!(PROBE.decide_in(files, "cheap/image"), Take::Presence); - assert_eq!(PROBE.decide_in(files, "costly/image"), Take::Presence); - assert_eq!(PROBE.decide_in(files, "one/file"), Take::Payload); - assert_eq!(PROBE.decide_in(files, "old/text"), Take::Payload); - assert_eq!(PROBE.decide_in(files, "hangs/forever"), Take::Never); - } - - #[test] - fn outside_a_file_copy_the_family_changes_nothing() { - for family in [None, Some(Family::Text), Some(Family::Image)] { - for id in [ - "cheap/image", - "costly/image", - "old/text", - "hangs/forever", - "huge/icon", - "who/knows", - ] { - assert_eq!( - PROBE.decide_in(family, id), - PROBE.decide(id), - "{family:?} {id}" - ); - } - } - } - - #[test] - fn a_copied_image_notes_the_page_archive_and_a_text_keeps_it() { - const ARCHIVING: Catalog = Catalog { - wanted: &["plain/text", "cheap/image", "whole/page"], - ..PROBE - }; - assert_eq!( - ARCHIVING.decide_in(Some(Family::Image), "whole/page"), - Take::Presence, - "a 43 KB logo shouldn't cost the whole page" - ); - assert_eq!( - ARCHIVING.decide_in(Some(Family::Text), "whole/page"), - Take::Payload, - "for styled text, the archive is the content" - ); - assert_eq!(ARCHIVING.decide_in(None, "whole/page"), Take::Payload); - assert_eq!( - ARCHIVING.decide_in(Some(Family::Image), "cheap/image"), - Take::Payload - ); - } - - #[test] - fn a_file_copy_notes_the_icon_under_its_legacy_name_too() { - const ICONIC: Catalog = Catalog { - aliases: &[("legacy/picture", "cheap/image")], - ..PROBE - }; - assert_eq!( - ICONIC.decide_in(Some(Family::Files), "legacy/picture"), - Take::Presence - ); - assert_eq!( - ICONIC.decide_in(Some(Family::Text), "legacy/picture"), - Take::Payload - ); - } -} +#[path = "formats_test.rs"] +mod tests; #[cfg(test)] -mod windows_needs { - use super::tests::PROBE; - use super::*; - - #[test] - fn a_spreadsheet_is_text_even_when_it_offers_a_picture_of_itself() { - let excel = ["plain/text", "cheap/image", "embedded/document"]; - assert_eq!(PROBE.classify(&excel), Some(Family::Text)); - } - - #[test] - fn an_image_with_its_address_alongside_is_still_an_image() { - let browser = ["plain/text", "cheap/image"]; - assert_eq!(PROBE.classify(&browser), Some(Family::Image)); - } - - #[test] - fn an_embeddable_marker_without_text_does_not_hide_the_image() { - assert_eq!( - PROBE.classify(&["cheap/image", "embedded/document"]), - Some(Family::Image) - ); - } - - #[test] - fn files_still_win_over_everything() { - assert_eq!( - PROBE.classify(&["one/file", "plain/text", "cheap/image", "embedded/document"]), - Some(Family::Files) - ); - } - - #[test] - fn the_costlier_wrapping_of_the_same_text_is_only_noted() { - let firefox = ["costly/markup", "cheap/markup", "plain/text"]; - assert!(PROBE.costlier_twin("costly/markup", &firefox)); - assert!(!PROBE.costlier_twin("cheap/markup", &firefox)); - } - - #[test] - fn the_only_wrapping_on_offer_is_never_the_costlier_one() { - assert!(!PROBE.costlier_twin("costly/markup", &["costly/markup"])); - assert!(!PROBE.costlier_twin("costly/image", &["costly/image"])); - } - - #[test] - fn a_type_in_no_group_has_no_twin() { - assert!(!PROBE.costlier_twin("plain/text", &["plain/text", "cheap/markup"])); - } - - #[test] - fn a_marker_that_says_zero_refuses_the_copy() { - assert_eq!( - PROBE.declines("may/record", &[0, 0, 0, 0]), - Some(Refusal::Declined("may/record")) - ); - assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0]), None); - } - - #[test] - fn a_marker_too_short_to_read_is_obeyed_anyway() { - for unreadable in [&[][..], &[0][..], &[0, 0, 0][..], &[1, 0, 0][..]] { - assert_eq!( - PROBE.declines("may/record", unreadable), - Some(Refusal::Declined("may/record")), - "{unreadable:?}" - ); - } - } - - #[test] - fn only_a_declared_marker_is_read() { - assert_eq!(PROBE.declines("plain/text", &[0, 0, 0, 0]), None); - assert_eq!(PROBE.declines("secret/marker", &[0, 0, 0, 0]), None); - } - - #[test] - fn a_longer_marker_is_read_by_its_first_four_bytes() { - assert_eq!( - PROBE.declines("may/record", &[0, 0, 0, 0, 9, 9]), - Some(Refusal::Declined("may/record")) - ); - assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0, 0, 0]), None); - } - - #[test] - fn the_four_bytes_are_one_little_endian_number() { - assert_eq!(PROBE.declines("may/record", &[0, 0, 0, 1]), None); - assert_eq!(PROBE.declines("may/record", &[0, 1, 0, 0]), None); - } - - #[test] - fn either_road_to_a_refusal_names_the_marker() { - assert_eq!( - PROBE - .refusal(&["plain/text", "secret/marker"]) - .map(Refusal::marker), - Some("secret/marker") - ); - assert_eq!( - PROBE - .declines("may/record", &[0, 0, 0, 0]) - .map(Refusal::marker), - Some("may/record") - ); - } -} +#[path = "formats_windows_needs.rs"] +mod windows_needs; #[cfg(test)] -mod properties { - use super::tests::PROBE; - use super::*; - use proptest::prelude::*; - - fn any_id() -> impl Strategy { - prop_oneof![ - Just("plain/text".to_string()), - Just("old/text".to_string()), - Just("hangs/forever".to_string()), - Just("huge/icon".to_string()), - Just("cheap/image".to_string()), - Just("costly/image".to_string()), - Just("cheap/markup".to_string()), - Just("costly/markup".to_string()), - Just("embedded/document".to_string()), - Just("one/file".to_string()), - "[a-z]{0,12}/[a-z]{0,12}", - "dyn\\.[a-z0-9]{0,20}", - Just(String::new()), - ] - } - - proptest! { - #[test] - fn canonical_is_idempotent(id in any_id()) { - let once = PROBE.canonical(&id).to_string(); - prop_assert_eq!(PROBE.canonical(&once), once.as_str()); - } - - #[test] - fn what_hangs_is_never_payload(id in any_id()) { - if PROBE.hangs.contains(&PROBE.canonical(&id)) { - prop_assert_eq!(PROBE.decide(&id), Take::Never); - } - } - - #[test] - fn deciding_is_deterministic(id in any_id()) { - prop_assert_eq!(PROBE.decide(&id), PROBE.decide(&id)); - } - - #[test] - fn the_chosen_image_was_on_offer(ids in prop::collection::vec(any_id(), 0..8)) { - let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); - if let Some(chosen) = PROBE.preferred_image(&refs) { - let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); - prop_assert!(canonical.contains(&chosen)); - } - } - - #[test] - fn a_classification_is_backed_by_a_type(ids in prop::collection::vec(any_id(), 0..8)) { - let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); - let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); - match PROBE.classify(&refs) { - Some(Family::Files) => { - prop_assert!(canonical.iter().any(|id| PROBE.files.contains(id))) - } - Some(Family::Image) => prop_assert!( - canonical - .iter() - .any(|id| PROBE.images_by_preference.contains(id)) - ), - Some(Family::Text) => { - prop_assert!(canonical.iter().any(|id| PROBE.text.contains(id))) - } - None => {} - } - } - - #[test] - fn exactly_one_of_each_group_is_kept(ids in prop::collection::vec(any_id(), 0..8)) { - let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); - for group in std::iter::once(PROBE.images_by_preference) - .chain(PROBE.equivalents.iter().copied()) - { - let kept: Vec<&&str> = group - .iter() - .filter(|id| refs.iter().any(|one| PROBE.canonical(one) == **id)) - .filter(|id| !PROBE.costlier_twin(id, &refs)) - .collect(); - let offered = group - .iter() - .any(|id| refs.iter().any(|one| PROBE.canonical(one) == *id)); - prop_assert_eq!(kept.len(), usize::from(offered)); - } - } - - #[test] - fn only_a_zero_in_a_declared_marker_refuses( - id in any_id(), - value in prop::collection::vec(any::(), 0..8), - ) { - match PROBE.declines(&id, &value) { - Some(Refusal::Declined(marker)) => { - prop_assert!(PROBE.denied_when_zero.contains(&marker)); - if let [a, b, c, d, ..] = value[..] { - prop_assert_eq!(u32::from_le_bytes([a, b, c, d]), 0); - } - } - Some(other) => prop_assert!(false, "not a value-based refusal: {:?}", other), - None => {} - } - } - - #[test] - fn the_class_does_not_depend_on_the_order_offered( - ids in prop::collection::vec(any_id(), 0..8), - ) { - let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); - let mut backwards = refs.clone(); - backwards.reverse(); - prop_assert_eq!(PROBE.classify(&refs), PROBE.classify(&backwards)); - } - } -} +#[path = "formats_properties.rs"] +mod properties; diff --git a/crates/cp-core/src/formats_properties.rs b/crates/cp-core/src/formats_properties.rs new file mode 100644 index 00000000..b9d73310 --- /dev/null +++ b/crates/cp-core/src/formats_properties.rs @@ -0,0 +1,115 @@ +use super::tests::PROBE; +use super::*; +use proptest::prelude::*; + +fn any_id() -> impl Strategy { + prop_oneof![ + Just("plain/text".to_string()), + Just("old/text".to_string()), + Just("hangs/forever".to_string()), + Just("huge/icon".to_string()), + Just("cheap/image".to_string()), + Just("costly/image".to_string()), + Just("cheap/markup".to_string()), + Just("costly/markup".to_string()), + Just("embedded/document".to_string()), + Just("one/file".to_string()), + "[a-z]{0,12}/[a-z]{0,12}", + "dyn\\.[a-z0-9]{0,20}", + Just(String::new()), + ] +} + +proptest! { + #[test] + fn canonical_is_idempotent(id in any_id()) { + let once = PROBE.canonical(&id).to_string(); + prop_assert_eq!(PROBE.canonical(&once), once.as_str()); + } + + #[test] + fn what_hangs_is_never_payload(id in any_id()) { + if PROBE.hangs.contains(&PROBE.canonical(&id)) { + prop_assert_eq!(PROBE.decide(&id), Take::Never); + } + } + + #[test] + fn deciding_is_deterministic(id in any_id()) { + prop_assert_eq!(PROBE.decide(&id), PROBE.decide(&id)); + } + + #[test] + fn the_chosen_image_was_on_offer(ids in prop::collection::vec(any_id(), 0..8)) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + if let Some(chosen) = PROBE.preferred_image(&refs) { + let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); + prop_assert!(canonical.contains(&chosen)); + } + } + + #[test] + fn a_classification_is_backed_by_a_type(ids in prop::collection::vec(any_id(), 0..8)) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + let canonical: Vec<&str> = refs.iter().map(|id| PROBE.canonical(id)).collect(); + match PROBE.classify(&refs) { + Some(Family::Files) => { + prop_assert!(canonical.iter().any(|id| PROBE.files.contains(id))) + } + Some(Family::Image) => prop_assert!( + canonical + .iter() + .any(|id| PROBE.images_by_preference.contains(id)) + ), + Some(Family::Text) => { + prop_assert!(canonical.iter().any(|id| PROBE.text.contains(id))) + } + None => {} + } + } + + #[test] + fn exactly_one_of_each_group_is_kept(ids in prop::collection::vec(any_id(), 0..8)) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + for group in std::iter::once(PROBE.images_by_preference) + .chain(PROBE.equivalents.iter().copied()) + { + let kept: Vec<&&str> = group + .iter() + .filter(|id| refs.iter().any(|one| PROBE.canonical(one) == **id)) + .filter(|id| !PROBE.costlier_twin(id, &refs)) + .collect(); + let offered = group + .iter() + .any(|id| refs.iter().any(|one| PROBE.canonical(one) == *id)); + prop_assert_eq!(kept.len(), usize::from(offered)); + } + } + + #[test] + fn only_a_zero_in_a_declared_marker_refuses( + id in any_id(), + value in prop::collection::vec(any::(), 0..8), + ) { + match PROBE.declines(&id, &value) { + Some(Refusal::Declined(marker)) => { + prop_assert!(PROBE.denied_when_zero.contains(&marker)); + if let [a, b, c, d, ..] = value[..] { + prop_assert_eq!(u32::from_le_bytes([a, b, c, d]), 0); + } + } + Some(other) => prop_assert!(false, "not a value-based refusal: {:?}", other), + None => {} + } + } + + #[test] + fn the_class_does_not_depend_on_the_order_offered( + ids in prop::collection::vec(any_id(), 0..8), + ) { + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + let mut backwards = refs.clone(); + backwards.reverse(); + prop_assert_eq!(PROBE.classify(&refs), PROBE.classify(&backwards)); + } +} diff --git a/crates/cp-core/src/formats_test.rs b/crates/cp-core/src/formats_test.rs new file mode 100644 index 00000000..5b2fcbcb --- /dev/null +++ b/crates/cp-core/src/formats_test.rs @@ -0,0 +1,222 @@ +use super::*; + +pub(super) const PROBE: Catalog = Catalog { + hangs: &["hangs/forever"], + wasteful: &["huge/icon"], + wanted: &[ + "plain/text", + "rich/text", + "cheap/image", + "costly/image", + "one/file", + ], + aliases: &[("old/text", "plain/text")], + concealed: &["secret/marker"], + denied_when_zero: &["may/record"], + opaque_prefixes: &["dyn."], + text: &["plain/text", "rich/text"], + files: &["one/file"], + images_by_preference: &["cheap/image", "costly/image"], + equivalents: &[&["cheap/markup", "costly/markup"]], + embeddable: &["embedded/document"], + page_archives: &["whole/page"], +}; + +const NOTHING: Catalog = Catalog { + hangs: &[], + wasteful: &[], + wanted: &[], + aliases: &[], + concealed: &[], + denied_when_zero: &[], + opaque_prefixes: &[], + text: &[], + files: &[], + images_by_preference: &[], + equivalents: &[], + embeddable: &[], + page_archives: &[], +}; + +#[test] +fn an_empty_catalogue_notes_everything_and_promises_nothing() { + assert_eq!(NOTHING.decide("any/thing"), Take::Presence); + assert_eq!(NOTHING.decide(""), Take::Presence); + assert_eq!(NOTHING.classify(&["any/thing"]), None); + assert_eq!(NOTHING.preferred_image(&["any/thing"]), None); + assert_eq!(NOTHING.refusal(&["any/thing"]), None); + assert_eq!(NOTHING.declines("any/thing", &[0, 0, 0, 0]), None); + assert!(!NOTHING.costlier_twin("any/thing", &["other/thing"])); + assert_eq!(NOTHING.canonical("x"), "x"); +} + +#[test] +fn nothing_offered_at_all_is_not_an_item() { + assert_eq!(PROBE.classify(&[]), None); + assert_eq!(PROBE.preferred_image(&[]), None); + assert_eq!(PROBE.refusal(&[]), None); +} + +#[test] +fn an_empty_type_name_is_just_unknown() { + assert_eq!(PROBE.decide(""), Take::Presence); + assert_eq!(PROBE.canonical(""), ""); +} + +#[test] +fn what_hangs_is_never_asked_for() { + assert_eq!(PROBE.decide("hangs/forever"), Take::Never); +} + +#[test] +fn the_expensive_and_the_unknown_are_only_noted() { + assert_eq!(PROBE.decide("huge/icon"), Take::Presence); + assert_eq!(PROBE.decide("dyn.abc123"), Take::Presence); + assert_eq!(PROBE.decide("who/knows"), Take::Presence); +} + +#[test] +fn wasteful_beats_wanted_when_a_type_is_in_both() { + const GREEDY: Catalog = Catalog { + hangs: &[], + wasteful: &["costly/image"], + wanted: &["costly/image", "plain/text"], + aliases: &[], + concealed: &[], + denied_when_zero: &[], + opaque_prefixes: &[], + text: &["plain/text"], + files: &[], + images_by_preference: &["costly/image"], + equivalents: &[], + embeddable: &[], + page_archives: &[], + }; + assert_eq!( + GREEDY.decide("costly/image"), + Take::Presence, + "being on the wanted list doesn't save a type that wastes" + ); + assert_eq!(GREEDY.decide("plain/text"), Take::Payload); +} + +#[test] +fn a_legacy_name_is_the_modern_one() { + assert_eq!(PROBE.canonical("old/text"), "plain/text"); + assert_eq!(PROBE.decide("old/text"), Take::Payload); +} + +#[test] +fn the_cheapest_representation_wins() { + assert_eq!( + PROBE.preferred_image(&["costly/image", "cheap/image"]), + Some("cheap/image") + ); + assert_eq!( + PROBE.preferred_image(&["costly/image"]), + Some("costly/image") + ); + assert_eq!(PROBE.preferred_image(&["plain/text"]), None); +} + +#[test] +fn a_secret_is_recognised_by_the_type_alone() { + assert_eq!( + PROBE.refusal(&["plain/text", "secret/marker"]), + Some(Refusal::Marked("secret/marker")) + ); + assert_eq!(PROBE.refusal(&["plain/text"]), None); +} + +#[test] +fn classifying_never_discards_the_rest() { + let mixed = ["plain/text", "cheap/image", "one/file"]; + assert_eq!(PROBE.classify(&mixed), Some(Family::Files)); + let kept: Vec<&str> = mixed + .iter() + .filter(|id| PROBE.decide(id) == Take::Payload) + .copied() + .collect(); + assert_eq!( + kept.len(), + 3, + "classifying isn't picking one and discarding the rest" + ); +} + +#[test] +fn an_image_offered_with_its_text_is_still_an_image() { + assert_eq!( + PROBE.classify(&["plain/text", "cheap/image"]), + Some(Family::Image) + ); +} + +#[test] +fn a_copied_file_keeps_its_path_and_notes_its_icon() { + let files = Some(Family::Files); + assert_eq!(PROBE.decide_in(files, "cheap/image"), Take::Presence); + assert_eq!(PROBE.decide_in(files, "costly/image"), Take::Presence); + assert_eq!(PROBE.decide_in(files, "one/file"), Take::Payload); + assert_eq!(PROBE.decide_in(files, "old/text"), Take::Payload); + assert_eq!(PROBE.decide_in(files, "hangs/forever"), Take::Never); +} + +#[test] +fn outside_a_file_copy_the_family_changes_nothing() { + for family in [None, Some(Family::Text), Some(Family::Image)] { + for id in [ + "cheap/image", + "costly/image", + "old/text", + "hangs/forever", + "huge/icon", + "who/knows", + ] { + assert_eq!( + PROBE.decide_in(family, id), + PROBE.decide(id), + "{family:?} {id}" + ); + } + } +} + +#[test] +fn a_copied_image_notes_the_page_archive_and_a_text_keeps_it() { + const ARCHIVING: Catalog = Catalog { + wanted: &["plain/text", "cheap/image", "whole/page"], + ..PROBE + }; + assert_eq!( + ARCHIVING.decide_in(Some(Family::Image), "whole/page"), + Take::Presence, + "a 43 KB logo shouldn't cost the whole page" + ); + assert_eq!( + ARCHIVING.decide_in(Some(Family::Text), "whole/page"), + Take::Payload, + "for styled text, the archive is the content" + ); + assert_eq!(ARCHIVING.decide_in(None, "whole/page"), Take::Payload); + assert_eq!( + ARCHIVING.decide_in(Some(Family::Image), "cheap/image"), + Take::Payload + ); +} + +#[test] +fn a_file_copy_notes_the_icon_under_its_legacy_name_too() { + const ICONIC: Catalog = Catalog { + aliases: &[("legacy/picture", "cheap/image")], + ..PROBE + }; + assert_eq!( + ICONIC.decide_in(Some(Family::Files), "legacy/picture"), + Take::Presence + ); + assert_eq!( + ICONIC.decide_in(Some(Family::Text), "legacy/picture"), + Take::Payload + ); +} diff --git a/crates/cp-core/src/formats_windows_needs.rs b/crates/cp-core/src/formats_windows_needs.rs new file mode 100644 index 00000000..45456045 --- /dev/null +++ b/crates/cp-core/src/formats_windows_needs.rs @@ -0,0 +1,105 @@ +use super::tests::PROBE; +use super::*; + +#[test] +fn a_spreadsheet_is_text_even_when_it_offers_a_picture_of_itself() { + let excel = ["plain/text", "cheap/image", "embedded/document"]; + assert_eq!(PROBE.classify(&excel), Some(Family::Text)); +} + +#[test] +fn an_image_with_its_address_alongside_is_still_an_image() { + let browser = ["plain/text", "cheap/image"]; + assert_eq!(PROBE.classify(&browser), Some(Family::Image)); +} + +#[test] +fn an_embeddable_marker_without_text_does_not_hide_the_image() { + assert_eq!( + PROBE.classify(&["cheap/image", "embedded/document"]), + Some(Family::Image) + ); +} + +#[test] +fn files_still_win_over_everything() { + assert_eq!( + PROBE.classify(&["one/file", "plain/text", "cheap/image", "embedded/document"]), + Some(Family::Files) + ); +} + +#[test] +fn the_costlier_wrapping_of_the_same_text_is_only_noted() { + let firefox = ["costly/markup", "cheap/markup", "plain/text"]; + assert!(PROBE.costlier_twin("costly/markup", &firefox)); + assert!(!PROBE.costlier_twin("cheap/markup", &firefox)); +} + +#[test] +fn the_only_wrapping_on_offer_is_never_the_costlier_one() { + assert!(!PROBE.costlier_twin("costly/markup", &["costly/markup"])); + assert!(!PROBE.costlier_twin("costly/image", &["costly/image"])); +} + +#[test] +fn a_type_in_no_group_has_no_twin() { + assert!(!PROBE.costlier_twin("plain/text", &["plain/text", "cheap/markup"])); +} + +#[test] +fn a_marker_that_says_zero_refuses_the_copy() { + assert_eq!( + PROBE.declines("may/record", &[0, 0, 0, 0]), + Some(Refusal::Declined("may/record")) + ); + assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0]), None); +} + +#[test] +fn a_marker_too_short_to_read_is_obeyed_anyway() { + for unreadable in [&[][..], &[0][..], &[0, 0, 0][..], &[1, 0, 0][..]] { + assert_eq!( + PROBE.declines("may/record", unreadable), + Some(Refusal::Declined("may/record")), + "{unreadable:?}" + ); + } +} + +#[test] +fn only_a_declared_marker_is_read() { + assert_eq!(PROBE.declines("plain/text", &[0, 0, 0, 0]), None); + assert_eq!(PROBE.declines("secret/marker", &[0, 0, 0, 0]), None); +} + +#[test] +fn a_longer_marker_is_read_by_its_first_four_bytes() { + assert_eq!( + PROBE.declines("may/record", &[0, 0, 0, 0, 9, 9]), + Some(Refusal::Declined("may/record")) + ); + assert_eq!(PROBE.declines("may/record", &[1, 0, 0, 0, 0, 0]), None); +} + +#[test] +fn the_four_bytes_are_one_little_endian_number() { + assert_eq!(PROBE.declines("may/record", &[0, 0, 0, 1]), None); + assert_eq!(PROBE.declines("may/record", &[0, 1, 0, 0]), None); +} + +#[test] +fn either_road_to_a_refusal_names_the_marker() { + assert_eq!( + PROBE + .refusal(&["plain/text", "secret/marker"]) + .map(Refusal::marker), + Some("secret/marker") + ); + assert_eq!( + PROBE + .declines("may/record", &[0, 0, 0, 0]) + .map(Refusal::marker), + Some("may/record") + ); +} diff --git a/crates/cp-core/src/hash.rs b/crates/cp-core/src/hash.rs index 2fcf18ef..3fe65d10 100644 --- a/crates/cp-core/src/hash.rs +++ b/crates/cp-core/src/hash.rs @@ -22,110 +22,5 @@ pub fn content_hash(bytes: &[u8]) -> u64 { } #[cfg(test)] -mod tests { - use super::*; - - fn screenshot(shared_header: usize, tail: u8) -> Vec { - let mut one = vec![0xAB; shared_header]; - one.resize(1024 * 1024, tail); - one - } - - #[test] - fn two_captures_sharing_a_menu_bar_are_not_the_same_capture() { - let a = screenshot(512 * 1024, 0x01); - let b = screenshot(512 * 1024, 0x02); - assert_eq!(a.len(), b.len(), "same size, half the image identical"); - assert_ne!( - content_hash(&a), - content_hash(&b), - "sampling just the header would call them equal" - ); - } - - #[test] - fn two_long_texts_that_begin_alike_are_different_items() { - let shared = "x".repeat(100); - let a = format!("{shared}first"); - let b = format!("{shared}second"); - assert_ne!(content_hash(a.as_bytes()), content_hash(b.as_bytes())); - } - - #[test] - fn nothing_at_all_still_has_an_identity() { - assert_eq!(content_hash(&[]), content_hash(&[])); - assert_ne!(content_hash(&[]), content_hash(&[0])); - assert_ne!(content_hash(&[0]), content_hash(&[1])); - } - - #[test] - fn the_same_content_always_hashes_the_same() { - let big = screenshot(512 * 1024, 0x07); - assert_eq!(content_hash(&big), content_hash(&big.clone())); - } - - #[test] - fn the_sampling_reaches_the_end_of_the_buffer() { - let big = vec![0x11; 4 * 1024 * 1024]; - let mut tail_changed = big.clone(); - *tail_changed.last_mut().unwrap() = 0x22; - assert_ne!( - content_hash(&big), - content_hash(&tail_changed), - "if the blocks all fell at the start, this would go unnoticed" - ); - } - - #[test] - fn a_change_between_blocks_is_invisible_and_that_is_the_deal() { - let big = vec![0x11; 4 * 1024 * 1024]; - let mut hole = big.clone(); - hole[2 * 1024 * 1024] = 0x22; - assert_eq!( - content_hash(&big), - content_hash(&hole), - "if this changed, the sampling would have stopped being a sampling" - ); - } - - #[test] - fn the_two_paths_meet_at_the_threshold() { - let under = vec![0x33; WHOLE_UP_TO]; - let over = vec![0x33; WHOLE_UP_TO + 1]; - assert_eq!( - content_hash(&under), - xxh3_64(&under), - "up to the threshold, the whole content is looked at" - ); - assert_ne!( - content_hash(&over), - xxh3_64(&over), - "above it, it is sampled, so it does not match the direct hash" - ); - } - - #[test] - fn each_of_the_sixteen_blocks_is_looked_at() { - let size = 4 * 1024 * 1024; - let base = vec![0x44; size]; - let reference = content_hash(&base); - for block in 0..BLOCKS { - let mut poked = base.clone(); - let at = (size - BLOCK) * block / (BLOCKS - 1); - poked[at] = 0x55; - assert_ne!( - content_hash(&poked), - reference, - "block {block}, starting at {at}, is missing from the mix" - ); - } - } - - #[test] - fn length_alone_separates_two_otherwise_identical_samples() { - let a = vec![0x5A; 2 * 1024 * 1024]; - let mut b = a.clone(); - b.push(0x5A); - assert_ne!(content_hash(&a), content_hash(&b)); - } -} +#[path = "hash_test.rs"] +mod tests; diff --git a/crates/cp-core/src/hash_test.rs b/crates/cp-core/src/hash_test.rs new file mode 100644 index 00000000..6054b62a --- /dev/null +++ b/crates/cp-core/src/hash_test.rs @@ -0,0 +1,105 @@ +use super::*; + +fn screenshot(shared_header: usize, tail: u8) -> Vec { + let mut one = vec![0xAB; shared_header]; + one.resize(1024 * 1024, tail); + one +} + +#[test] +fn two_captures_sharing_a_menu_bar_are_not_the_same_capture() { + let a = screenshot(512 * 1024, 0x01); + let b = screenshot(512 * 1024, 0x02); + assert_eq!(a.len(), b.len(), "same size, half the image identical"); + assert_ne!( + content_hash(&a), + content_hash(&b), + "sampling just the header would call them equal" + ); +} + +#[test] +fn two_long_texts_that_begin_alike_are_different_items() { + let shared = "x".repeat(100); + let a = format!("{shared}first"); + let b = format!("{shared}second"); + assert_ne!(content_hash(a.as_bytes()), content_hash(b.as_bytes())); +} + +#[test] +fn nothing_at_all_still_has_an_identity() { + assert_eq!(content_hash(&[]), content_hash(&[])); + assert_ne!(content_hash(&[]), content_hash(&[0])); + assert_ne!(content_hash(&[0]), content_hash(&[1])); +} + +#[test] +fn the_same_content_always_hashes_the_same() { + let big = screenshot(512 * 1024, 0x07); + assert_eq!(content_hash(&big), content_hash(&big.clone())); +} + +#[test] +fn the_sampling_reaches_the_end_of_the_buffer() { + let big = vec![0x11; 4 * 1024 * 1024]; + let mut tail_changed = big.clone(); + *tail_changed.last_mut().unwrap() = 0x22; + assert_ne!( + content_hash(&big), + content_hash(&tail_changed), + "if the blocks all fell at the start, this would go unnoticed" + ); +} + +#[test] +fn a_change_between_blocks_is_invisible_and_that_is_the_deal() { + let big = vec![0x11; 4 * 1024 * 1024]; + let mut hole = big.clone(); + hole[2 * 1024 * 1024] = 0x22; + assert_eq!( + content_hash(&big), + content_hash(&hole), + "if this changed, the sampling would have stopped being a sampling" + ); +} + +#[test] +fn the_two_paths_meet_at_the_threshold() { + let under = vec![0x33; WHOLE_UP_TO]; + let over = vec![0x33; WHOLE_UP_TO + 1]; + assert_eq!( + content_hash(&under), + xxh3_64(&under), + "up to the threshold, the whole content is looked at" + ); + assert_ne!( + content_hash(&over), + xxh3_64(&over), + "above it, it is sampled, so it does not match the direct hash" + ); +} + +#[test] +fn each_of_the_sixteen_blocks_is_looked_at() { + let size = 4 * 1024 * 1024; + let base = vec![0x44; size]; + let reference = content_hash(&base); + for block in 0..BLOCKS { + let mut poked = base.clone(); + let at = (size - BLOCK) * block / (BLOCKS - 1); + poked[at] = 0x55; + assert_ne!( + content_hash(&poked), + reference, + "block {block}, starting at {at}, is missing from the mix" + ); + } +} + +#[test] +fn length_alone_separates_two_otherwise_identical_samples() { + let a = vec![0x5A; 2 * 1024 * 1024]; + let mut b = a.clone(); + b.push(0x5A); + assert_ne!(content_hash(&a), content_hash(&b)); +} diff --git a/crates/cp-core/src/identity.rs b/crates/cp-core/src/identity.rs index 1ba2fecb..5fa4c127 100644 --- a/crates/cp-core/src/identity.rs +++ b/crates/cp-core/src/identity.rs @@ -57,104 +57,5 @@ fn find(bytes: &[u8], marker: &[u8]) -> Option { } #[cfg(test)] -mod tests { - use super::*; - - const DOCS: &str = "hi"; - - #[test] - fn the_guid_google_writes_on_every_copy_leaves_the_identity() { - let again = DOCS.replace( - "4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70", - "0c9d8e7f-7fff-aaaa-bbbb-000000000001", - ); - assert_ne!(DOCS, again); - assert_eq!( - stable("public.html", DOCS.as_bytes()), - stable("public.html", again.as_bytes()) - ); - assert_eq!( - stable("HTML Format", DOCS.as_bytes()), - stable("HTML Format", again.as_bytes()), - "on Windows the same HTML arrives under another name" - ); - let kept = stable("public.html", DOCS.as_bytes()); - assert!( - std::str::from_utf8(&kept) - .unwrap() - .contains("id=\"docs-internal-guid-\">hi"), - "the marker is left without its variable part, and the rest untouched" - ); - } - - #[test] - fn markup_without_anything_volatile_is_borrowed_not_copied() { - let html = b"hi"; - assert!(matches!(stable("public.html", html), Cow::Borrowed(_))); - assert_eq!(stable("public.html", html).as_ref(), html); - } - - #[test] - fn only_markup_is_looked_at() { - let text = b"paste docs-internal-guid-1234 as is"; - assert!(matches!( - stable("public.utf8-plain-text", text), - Cow::Borrowed(_) - )); - assert!(matches!(stable("public.png", text), Cow::Borrowed(_))); - assert!(matches!(stable("public.html", text), Cow::Owned(_))); - assert!(matches!(stable("text/html", text), Cow::Owned(_))); - assert!(is_markup("HTML Format")); - assert!(!is_markup("public.rtf")); - assert!(!is_markup("com.apple.webarchive")); - } - - #[test] - fn a_rendering_of_the_content_does_not_carry_its_identity() { - for id in [ - "public.rtf", - "com.apple.flat-rtfd", - "com.apple.webarchive", - "com.adobe.pdf", - "Rich Text Format", - "text/rtf", - ] { - assert!(!bears_identity(id), "{id}"); - } - for id in [ - "public.utf8-plain-text", - "public.html", - "HTML Format", - "public.png", - "public.tiff", - "public.file-url", - "text/plain", - "image/png", - ] { - assert!(bears_identity(id), "{id}"); - } - } - - #[test] - fn every_occurrence_goes_and_what_follows_the_guid_stays() { - let html = - b"xyz"; - let kept = stable("public.html", html); - assert_eq!( - kept.as_ref(), - b"xyz" - ); - let at_the_end = b""; - assert_eq!( - stable("public.html", upper).as_ref(), - b"id=\"docs-internal-guid-\">", - "the GUID may come in uppercase" - ); - } -} +#[path = "identity_test.rs"] +mod tests; diff --git a/crates/cp-core/src/identity_test.rs b/crates/cp-core/src/identity_test.rs new file mode 100644 index 00000000..cf42178e --- /dev/null +++ b/crates/cp-core/src/identity_test.rs @@ -0,0 +1,98 @@ +use super::*; + +const DOCS: &str = "hi"; + +#[test] +fn the_guid_google_writes_on_every_copy_leaves_the_identity() { + let again = DOCS.replace( + "4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70", + "0c9d8e7f-7fff-aaaa-bbbb-000000000001", + ); + assert_ne!(DOCS, again); + assert_eq!( + stable("public.html", DOCS.as_bytes()), + stable("public.html", again.as_bytes()) + ); + assert_eq!( + stable("HTML Format", DOCS.as_bytes()), + stable("HTML Format", again.as_bytes()), + "on Windows the same HTML arrives under another name" + ); + let kept = stable("public.html", DOCS.as_bytes()); + assert!( + std::str::from_utf8(&kept) + .unwrap() + .contains("id=\"docs-internal-guid-\">hi"), + "the marker is left without its variable part, and the rest untouched" + ); +} + +#[test] +fn markup_without_anything_volatile_is_borrowed_not_copied() { + let html = b"hi"; + assert!(matches!(stable("public.html", html), Cow::Borrowed(_))); + assert_eq!(stable("public.html", html).as_ref(), html); +} + +#[test] +fn only_markup_is_looked_at() { + let text = b"paste docs-internal-guid-1234 as is"; + assert!(matches!( + stable("public.utf8-plain-text", text), + Cow::Borrowed(_) + )); + assert!(matches!(stable("public.png", text), Cow::Borrowed(_))); + assert!(matches!(stable("public.html", text), Cow::Owned(_))); + assert!(matches!(stable("text/html", text), Cow::Owned(_))); + assert!(is_markup("HTML Format")); + assert!(!is_markup("public.rtf")); + assert!(!is_markup("com.apple.webarchive")); +} + +#[test] +fn a_rendering_of_the_content_does_not_carry_its_identity() { + for id in [ + "public.rtf", + "com.apple.flat-rtfd", + "com.apple.webarchive", + "com.adobe.pdf", + "Rich Text Format", + "text/rtf", + ] { + assert!(!bears_identity(id), "{id}"); + } + for id in [ + "public.utf8-plain-text", + "public.html", + "HTML Format", + "public.png", + "public.tiff", + "public.file-url", + "text/plain", + "image/png", + ] { + assert!(bears_identity(id), "{id}"); + } +} + +#[test] +fn every_occurrence_goes_and_what_follows_the_guid_stays() { + let html = b"xyz"; + let kept = stable("public.html", html); + assert_eq!( + kept.as_ref(), + b"xyz" + ); + let at_the_end = b""; + assert_eq!( + stable("public.html", upper).as_ref(), + b"id=\"docs-internal-guid-\">", + "the GUID may come in uppercase" + ); +} diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 2422ad12..9c9a8a79 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -131,329 +131,9 @@ impl Item { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn small_things_stay_in_the_row() { - let small = vec![0u8; 1024]; - assert!(matches!(Payload::stored(small), Payload::Inline(_))); - } - - #[test] - fn the_boundary_belongs_to_the_row() { - assert!(matches!( - Payload::stored(vec![0u8; INLINE_UP_TO]), - Payload::Inline(_) - )); - assert!(matches!( - Payload::stored(vec![0u8; INLINE_UP_TO + 1]), - Payload::Blob(_) - )); - } - - #[test] - fn the_three_placements_have_exact_boundaries() { - assert_eq!(placement(0), Placement::Row); - assert_eq!(placement(INLINE_UP_TO), Placement::Row); - assert_eq!(placement(INLINE_UP_TO + 1), Placement::Blob); - assert_eq!(placement(BLOB_UP_TO), Placement::Blob); - assert_eq!(placement(BLOB_UP_TO + 1), Placement::Refused); - assert_eq!(placement(usize::MAX), Placement::Refused); - } - - #[test] - fn what_does_not_fit_is_recorded_not_dropped() { - let huge = Payload::TooBig { - size: BLOB_UP_TO + 1, - }; - assert_eq!(huge.size(), Some(BLOB_UP_TO + 1), "the size is kept"); - } - - #[test] - fn an_announced_type_without_a_size_is_still_a_format() { - let announced = Format { - id: "com.apple.icns".into(), - payload: Payload::Announced { size: None }, - }; - let item = Item { - kind: None, - formats: vec![announced], - }; - assert!(item.format("com.apple.icns").is_some()); - assert_eq!(item.stored_bytes(), 0, "announced is not stored"); - } - - #[test] - fn two_items_that_look_alike_but_differ_have_different_identities() { - let one = Item { - kind: None, - formats: vec![Format { - id: "public.png".into(), - payload: Payload::Inline(vec![1, 2, 3]), - }], - }; - let other = Item { - kind: None, - formats: vec![Format { - id: "public.png".into(), - payload: Payload::Inline(vec![1, 2, 4]), - }], - }; - assert_ne!( - one.fingerprint(), - other.fingerprint(), - "two different captures with the same empty preview" - ); - } - - #[test] - fn the_identity_does_not_depend_on_the_order_of_the_formats() { - let a = Format { - id: "public.rtf".into(), - payload: Payload::Inline(vec![9]), - }; - let b = Format { - id: "public.utf8-plain-text".into(), - payload: Payload::Inline(vec![8]), - }; - let one = Item { - kind: None, - formats: vec![a.clone(), b.clone()], - }; - let other = Item { - kind: None, - formats: vec![b, a], - }; - assert_eq!(one.fingerprint(), other.fingerprint()); - } - - #[test] - fn two_copies_of_the_same_google_document_are_one_item() { - let docs = |guid: &str| { - Item { - kind: Some(crate::kind::Kind::Text), - formats: vec![ - Format { - id: "public.html".into(), - payload: Payload::Inline( - format!("hi") - .into_bytes(), - ), - }, - Format { - id: "public.utf8-plain-text".into(), - payload: Payload::Inline(b"hi".to_vec()), - }, - ], - } - }; - let first = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); - let second = docs("0c9d8e7f-7fff-aaaa-bbbb-000000000001"); - assert_ne!(first, second, "the bytes really do differ"); - assert_eq!(first.fingerprint(), second.fingerprint()); - assert_ne!( - first.fingerprint(), - Item::plain("hi").fingerprint(), - "plain text copied from where it was pasted is still a different item" - ); - let other_words = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); - let mut changed = other_words.clone(); - changed.formats[1].payload = Payload::Inline(b"bye".to_vec()); - assert_ne!(other_words.fingerprint(), changed.fingerprint()); - } - - #[test] - fn two_copies_of_the_same_word_paragraph_are_one_item() { - let word = |rsid: &str, base: &str, stamp: u8| Item { - kind: Some(crate::kind::Kind::Text), - formats: vec![ - Format { - id: "public.rtf".into(), - payload: Payload::Inline( - format!("{{\\rtf1{{\\*\\rsidtbl \\rsid{rsid}}}\\insrsid{rsid} hi}}") - .into_bytes(), - ), - }, - Format { - id: "public.utf8-plain-text".into(), - payload: Payload::Inline(b"hi".to_vec()), - }, - Format { - id: "public.html".into(), - payload: Payload::Inline(b"

hi

".to_vec()), - }, - Format { - id: "com.apple.webarchive".into(), - payload: Payload::Inline(format!("applewebdata://{base}").into_bytes()), - }, - Format { - id: "com.apple.flat-rtfd".into(), - payload: Payload::Inline(vec![b'r', b't', b'f', b'd', stamp]), - }, - ], - }; - let first = word("11014195", "BD20AAE0-5062", 1); - let second = word("15277860", "85F1BD98-1A2D", 2); - assert_ne!(first, second); - assert_eq!( - first.fingerprint(), - second.fingerprint(), - "the rsids, the webarchive UUID and the RTFD timestamp are not content" - ); - let mut other_words = second.clone(); - other_words.formats[2].payload = Payload::Inline(b"

bye

".to_vec()); - assert_ne!(first.fingerprint(), other_words.fingerprint()); - } - - #[test] - fn a_copy_that_is_only_a_rendering_still_has_an_identity_of_its_own() { - let rtf = |body: &str| Item { - kind: Some(crate::kind::Kind::Text), - formats: vec![Format { - id: "public.rtf".into(), - payload: Payload::Inline(body.as_bytes().to_vec()), - }], - }; - assert_eq!( - rtf("{\\rtf1 a}").fingerprint(), - rtf("{\\rtf1 a}").fingerprint() - ); - assert_ne!( - rtf("{\\rtf1 a}").fingerprint(), - rtf("{\\rtf1 b}").fingerprint() - ); - assert_ne!( - rtf("{\\rtf1 a}").fingerprint(), - Item { - kind: None, - formats: vec![] - } - .fingerprint() - ); - } - - #[test] - fn what_was_only_announced_does_not_change_the_identity() { - let with_note = Item { - kind: None, - formats: vec![ - Format { - id: "public.utf8-plain-text".into(), - payload: Payload::Inline(b"hi".to_vec()), - }, - Format { - id: "com.apple.icns".into(), - payload: Payload::Announced { size: Some(999) }, - }, - ], - }; - let without = Item { - kind: None, - formats: vec![Format { - id: "public.utf8-plain-text".into(), - payload: Payload::Inline(b"hi".to_vec()), - }], - }; - assert_eq!(with_note.fingerprint(), without.fingerprint()); - } - - #[test] - fn an_item_says_when_it_needs_a_blob_store() { - let small = Item { - kind: None, - formats: vec![Format { - id: "t".into(), - payload: Payload::Inline(vec![0; 10]), - }], - }; - let big = Item { - kind: None, - formats: vec![Format { - id: "t".into(), - payload: Payload::Blob(vec![0; 10]), - }], - }; - assert!(!small.needs_blob_store()); - assert!(big.needs_blob_store()); - } - - #[test] - fn only_what_was_really_kept_counts_towards_the_size() { - let item = Item { - kind: None, - formats: vec![ - Format { - id: "a".into(), - payload: Payload::Inline(vec![0u8; 100]), - }, - Format { - id: "b".into(), - payload: Payload::Blob(vec![0u8; 900]), - }, - Format { - id: "c".into(), - payload: Payload::TooBig { size: 999_999 }, - }, - Format { - id: "d".into(), - payload: Payload::Absent, - }, - ], - }; - assert_eq!(item.stored_bytes(), 1000); - } -} +#[path = "item_test.rs"] +mod tests; #[cfg(test)] -mod properties { - use super::*; - use proptest::prelude::*; - - fn rank(placement: Placement) -> u8 { - match placement { - Placement::Row => 0, - Placement::Blob => 1, - Placement::Refused => 2, - } - } - - proptest! { - #[test] - fn bigger_never_lands_closer(a in 0usize..usize::MAX, b in 0usize..usize::MAX) { - let (small, big) = if a <= b { (a, b) } else { (b, a) }; - prop_assert!(rank(placement(small)) <= rank(placement(big))); - } - - #[test] - fn the_size_survives_the_decision(len in 0usize..200_000) { - let payload = Payload::stored(vec![0u8; len]); - prop_assert_eq!(payload.size(), Some(len)); - } - - #[test] - fn only_real_bytes_are_counted(lens in prop::collection::vec(0usize..2000, 0..12)) { - let expected: usize = lens.iter().sum(); - let item = Item { - kind: None, - formats: lens - .iter() - .enumerate() - .map(|(at, len)| Format { - id: format!("t{at}"), - payload: Payload::Inline(vec![0u8; *len]), - }) - .chain(std::iter::once(Format { - id: "announced".into(), - payload: Payload::Announced { size: Some(999_999) }, - })) - .chain(std::iter::once(Format { - id: "absent".into(), - payload: Payload::Absent, - })) - .collect(), - }; - prop_assert_eq!(item.stored_bytes(), expected); - } - } -} +#[path = "item_properties.rs"] +mod properties; diff --git a/crates/cp-core/src/item_properties.rs b/crates/cp-core/src/item_properties.rs new file mode 100644 index 00000000..c2aaa6c0 --- /dev/null +++ b/crates/cp-core/src/item_properties.rs @@ -0,0 +1,49 @@ +use super::*; +use proptest::prelude::*; + +fn rank(placement: Placement) -> u8 { + match placement { + Placement::Row => 0, + Placement::Blob => 1, + Placement::Refused => 2, + } +} + +proptest! { + #[test] + fn bigger_never_lands_closer(a in 0usize..usize::MAX, b in 0usize..usize::MAX) { + let (small, big) = if a <= b { (a, b) } else { (b, a) }; + prop_assert!(rank(placement(small)) <= rank(placement(big))); + } + + #[test] + fn the_size_survives_the_decision(len in 0usize..200_000) { + let payload = Payload::stored(vec![0u8; len]); + prop_assert_eq!(payload.size(), Some(len)); + } + + #[test] + fn only_real_bytes_are_counted(lens in prop::collection::vec(0usize..2000, 0..12)) { + let expected: usize = lens.iter().sum(); + let item = Item { + kind: None, + formats: lens + .iter() + .enumerate() + .map(|(at, len)| Format { + id: format!("t{at}"), + payload: Payload::Inline(vec![0u8; *len]), + }) + .chain(std::iter::once(Format { + id: "announced".into(), + payload: Payload::Announced { size: Some(999_999) }, + })) + .chain(std::iter::once(Format { + id: "absent".into(), + payload: Payload::Absent, + })) + .collect(), + }; + prop_assert_eq!(item.stored_bytes(), expected); + } +} diff --git a/crates/cp-core/src/item_test.rs b/crates/cp-core/src/item_test.rs new file mode 100644 index 00000000..d5df0e54 --- /dev/null +++ b/crates/cp-core/src/item_test.rs @@ -0,0 +1,271 @@ +use super::*; + +#[test] +fn small_things_stay_in_the_row() { + let small = vec![0u8; 1024]; + assert!(matches!(Payload::stored(small), Payload::Inline(_))); +} + +#[test] +fn the_boundary_belongs_to_the_row() { + assert!(matches!( + Payload::stored(vec![0u8; INLINE_UP_TO]), + Payload::Inline(_) + )); + assert!(matches!( + Payload::stored(vec![0u8; INLINE_UP_TO + 1]), + Payload::Blob(_) + )); +} + +#[test] +fn the_three_placements_have_exact_boundaries() { + assert_eq!(placement(0), Placement::Row); + assert_eq!(placement(INLINE_UP_TO), Placement::Row); + assert_eq!(placement(INLINE_UP_TO + 1), Placement::Blob); + assert_eq!(placement(BLOB_UP_TO), Placement::Blob); + assert_eq!(placement(BLOB_UP_TO + 1), Placement::Refused); + assert_eq!(placement(usize::MAX), Placement::Refused); +} + +#[test] +fn what_does_not_fit_is_recorded_not_dropped() { + let huge = Payload::TooBig { + size: BLOB_UP_TO + 1, + }; + assert_eq!(huge.size(), Some(BLOB_UP_TO + 1), "the size is kept"); +} + +#[test] +fn an_announced_type_without_a_size_is_still_a_format() { + let announced = Format { + id: "com.apple.icns".into(), + payload: Payload::Announced { size: None }, + }; + let item = Item { + kind: None, + formats: vec![announced], + }; + assert!(item.format("com.apple.icns").is_some()); + assert_eq!(item.stored_bytes(), 0, "announced is not stored"); +} + +#[test] +fn two_items_that_look_alike_but_differ_have_different_identities() { + let one = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1, 2, 3]), + }], + }; + let other = Item { + kind: None, + formats: vec![Format { + id: "public.png".into(), + payload: Payload::Inline(vec![1, 2, 4]), + }], + }; + assert_ne!( + one.fingerprint(), + other.fingerprint(), + "two different captures with the same empty preview" + ); +} + +#[test] +fn the_identity_does_not_depend_on_the_order_of_the_formats() { + let a = Format { + id: "public.rtf".into(), + payload: Payload::Inline(vec![9]), + }; + let b = Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(vec![8]), + }; + let one = Item { + kind: None, + formats: vec![a.clone(), b.clone()], + }; + let other = Item { + kind: None, + formats: vec![b, a], + }; + assert_eq!(one.fingerprint(), other.fingerprint()); +} + +#[test] +fn two_copies_of_the_same_google_document_are_one_item() { + let docs = |guid: &str| { + Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![ + Format { + id: "public.html".into(), + payload: Payload::Inline( + format!("hi") + .into_bytes(), + ), + }, + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hi".to_vec()), + }, + ], + } + }; + let first = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); + let second = docs("0c9d8e7f-7fff-aaaa-bbbb-000000000001"); + assert_ne!(first, second, "the bytes really do differ"); + assert_eq!(first.fingerprint(), second.fingerprint()); + assert_ne!( + first.fingerprint(), + Item::plain("hi").fingerprint(), + "plain text copied from where it was pasted is still a different item" + ); + let other_words = docs("4a1e6b2f-7fff-1d3e-8c5a-2b3c4d5e6f70"); + let mut changed = other_words.clone(); + changed.formats[1].payload = Payload::Inline(b"bye".to_vec()); + assert_ne!(other_words.fingerprint(), changed.fingerprint()); +} + +#[test] +fn two_copies_of_the_same_word_paragraph_are_one_item() { + let word = |rsid: &str, base: &str, stamp: u8| Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![ + Format { + id: "public.rtf".into(), + payload: Payload::Inline( + format!("{{\\rtf1{{\\*\\rsidtbl \\rsid{rsid}}}\\insrsid{rsid} hi}}") + .into_bytes(), + ), + }, + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hi".to_vec()), + }, + Format { + id: "public.html".into(), + payload: Payload::Inline(b"

hi

".to_vec()), + }, + Format { + id: "com.apple.webarchive".into(), + payload: Payload::Inline(format!("applewebdata://{base}").into_bytes()), + }, + Format { + id: "com.apple.flat-rtfd".into(), + payload: Payload::Inline(vec![b'r', b't', b'f', b'd', stamp]), + }, + ], + }; + let first = word("11014195", "BD20AAE0-5062", 1); + let second = word("15277860", "85F1BD98-1A2D", 2); + assert_ne!(first, second); + assert_eq!( + first.fingerprint(), + second.fingerprint(), + "the rsids, the webarchive UUID and the RTFD timestamp are not content" + ); + let mut other_words = second.clone(); + other_words.formats[2].payload = Payload::Inline(b"

bye

".to_vec()); + assert_ne!(first.fingerprint(), other_words.fingerprint()); +} + +#[test] +fn a_copy_that_is_only_a_rendering_still_has_an_identity_of_its_own() { + let rtf = |body: &str| Item { + kind: Some(crate::kind::Kind::Text), + formats: vec![Format { + id: "public.rtf".into(), + payload: Payload::Inline(body.as_bytes().to_vec()), + }], + }; + assert_eq!( + rtf("{\\rtf1 a}").fingerprint(), + rtf("{\\rtf1 a}").fingerprint() + ); + assert_ne!( + rtf("{\\rtf1 a}").fingerprint(), + rtf("{\\rtf1 b}").fingerprint() + ); + assert_ne!( + rtf("{\\rtf1 a}").fingerprint(), + Item { + kind: None, + formats: vec![] + } + .fingerprint() + ); +} + +#[test] +fn what_was_only_announced_does_not_change_the_identity() { + let with_note = Item { + kind: None, + formats: vec![ + Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hi".to_vec()), + }, + Format { + id: "com.apple.icns".into(), + payload: Payload::Announced { size: Some(999) }, + }, + ], + }; + let without = Item { + kind: None, + formats: vec![Format { + id: "public.utf8-plain-text".into(), + payload: Payload::Inline(b"hi".to_vec()), + }], + }; + assert_eq!(with_note.fingerprint(), without.fingerprint()); +} + +#[test] +fn an_item_says_when_it_needs_a_blob_store() { + let small = Item { + kind: None, + formats: vec![Format { + id: "t".into(), + payload: Payload::Inline(vec![0; 10]), + }], + }; + let big = Item { + kind: None, + formats: vec![Format { + id: "t".into(), + payload: Payload::Blob(vec![0; 10]), + }], + }; + assert!(!small.needs_blob_store()); + assert!(big.needs_blob_store()); +} + +#[test] +fn only_what_was_really_kept_counts_towards_the_size() { + let item = Item { + kind: None, + formats: vec![ + Format { + id: "a".into(), + payload: Payload::Inline(vec![0u8; 100]), + }, + Format { + id: "b".into(), + payload: Payload::Blob(vec![0u8; 900]), + }, + Format { + id: "c".into(), + payload: Payload::TooBig { size: 999_999 }, + }, + Format { + id: "d".into(), + payload: Payload::Absent, + }, + ], + }; + assert_eq!(item.stored_bytes(), 1000); +} diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index 8f607bef..0dcaa787 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -300,375 +300,17 @@ fn looks_like_code(text: &str) -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_class_goes_to_its_name_and_back() { - for kind in Kind::ALL { - assert_eq!(Kind::from_name(kind.as_str()), Some(kind)); - } - let mut names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.as_str()).collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), 15, "fifteen classes, fifteen distinct names"); - } - - #[test] - fn a_name_that_is_not_a_class_is_nobody() { - assert_eq!(Kind::from_name("Text"), None, "the name is exact"); - assert_eq!(Kind::from_name("imagen"), None); - assert_eq!(Kind::from_name(""), None); - } - - #[test] - fn the_classes_2x_already_recognised() { - assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); - assert_eq!(classify_text("#FF8800"), Kind::Color); - assert_eq!(classify_text("rgb(12, 34, 56)"), Kind::Color); - assert_eq!(classify_text("hsla(120, 50%, 50%, 0.5)"), Kind::Color); - assert_eq!(classify_text("192.168.1.1"), Kind::Ip); - assert_eq!( - classify_text("7ab3f6de-1c4b-4f5e-8a2d-9f0e1b2c3d4e"), - Kind::Uuid - ); - assert_eq!(classify_text("+34 600 123 456"), Kind::Phone); - assert_eq!(classify_text(r#"{"a": 1}"#), Kind::Json); - } - - #[test] - fn a_token_is_its_own_class_and_wins_over_the_rest() { - assert_eq!( - classify_text("ghp_not_a_real_token_for_tests_0000000000"), - Kind::Token - ); - assert_eq!( - classify_text(" sk-not-a-real-key-for-tests-0123456789\n"), - Kind::Token, - "trimmed, like everything else" - ); - assert_eq!(classify_text("sk-corto"), Kind::Text); - } - - #[test] - fn the_classes_2x_had_a_type_for_but_never_assigned() { - assert_eq!(classify_text("https://ejemplo.test/ruta?q=1"), Kind::Link); - assert_eq!(classify_file("cancion.mp3", false), Kind::Audio); - assert_eq!(classify_file("pelicula.MKV", false), Kind::Video); - assert_eq!(classify_file("foto.heic", false), Kind::Image); - assert_eq!(classify_file("Documentos", true), Kind::Folder); - assert_eq!(classify_file("informe.pdf", false), Kind::File); - } - - #[test] - fn code_is_recognised_which_2x_never_did() { - assert_eq!( - classify_text("fn main() {\n println!(\"hola\");\n}"), - Kind::Code - ); - assert_eq!( - classify_text("def suma(a, b):\n return a + b"), - Kind::Code - ); - assert_eq!( - classify_text("SELECT * FROM tabla WHERE id = 1;\nINSERT INTO otra VALUES (1);"), - Kind::Code - ); - } - - #[test] - fn ordinary_prose_is_never_code() { - for prose in [ - "Esto es una frase normal y corriente.", - "Nos vemos mañana si puedes", - "La reunión es a las cinco, en la sala grande", - "return", - ] { - assert_eq!(classify_text(prose), Kind::Text, "«{prose}»"); - } - } - - #[test] - fn something_that_only_looks_like_json_is_not_json() { - for fake in [ - "{esto no es json", - "[1, 2, 3", - "{\"sin cerrar\": \"comilla}", - ] { - assert_ne!(classify_text(fake), Kind::Json, "«{fake}»"); - } - assert_eq!(classify_text("[1, 2, 3]"), Kind::Json); - assert_eq!(classify_text(r#"{"anidado": {"a": [1, 2]}}"#), Kind::Json); - } - - #[test] - fn the_edges_of_each_class() { - assert_eq!(classify_text("#FFF"), Kind::Color); - assert_eq!(classify_text("#FF8800AA"), Kind::Color); - assert_ne!(classify_text("#GGGGGG"), Kind::Color); - assert_ne!(classify_text("256.1.1.1"), Kind::Ip); - assert_ne!(classify_text("1.2.3"), Kind::Ip); - assert_ne!(classify_text("no-es-un-uuid-de-verdad"), Kind::Uuid); - assert_ne!(classify_text("arroba sin@"), Kind::Email); - assert_ne!(classify_text("12345"), Kind::Phone); - } - - #[test] - fn several_lines_never_become_a_single_line_class() { - assert_eq!( - classify_text("alguien@ejemplo.test\notra línea"), - Kind::Text - ); - assert_eq!(classify_text("#FF8800\n#00FF88"), Kind::Text); - } - - #[test] - fn empty_and_blank_are_plain_text() { - assert_eq!(classify_text(""), Kind::Text); - assert_eq!(classify_text(" \n "), Kind::Text); - } - - #[test] - fn every_kind_has_a_stable_name_for_the_database() { - for kind in [ - Kind::Text, - Kind::Code, - Kind::Json, - Kind::Link, - Kind::Email, - Kind::Phone, - Kind::Color, - Kind::Ip, - Kind::Uuid, - Kind::Image, - Kind::File, - Kind::Folder, - Kind::Audio, - Kind::Video, - ] { - assert!(!kind.as_str().is_empty()); - assert_eq!(kind.as_str(), kind.as_str().to_lowercase()); - } - } -} +#[path = "kind_test.rs"] +mod tests; #[cfg(test)] -mod borders { - use super::*; - - #[test] - fn every_class_keeps_the_name_the_database_stores() { - for (kind, name) in [ - (Kind::Text, "text"), - (Kind::Code, "code"), - (Kind::Json, "json"), - (Kind::Link, "link"), - (Kind::Email, "email"), - (Kind::Phone, "phone"), - (Kind::Color, "color"), - (Kind::Ip, "ip"), - (Kind::Uuid, "uuid"), - (Kind::Image, "image"), - (Kind::File, "file"), - (Kind::Folder, "folder"), - (Kind::Audio, "audio"), - (Kind::Video, "video"), - ] { - assert_eq!( - kind.as_str(), - name, - "the name travels straight into the kind column" - ); - } - } - - #[test] - fn an_address_needs_every_piece_at_once() { - assert!(is_email("a@b.co")); - assert!(!is_email("@ejemplo.test"), "no user"); - assert!(!is_email("a@b@c.co"), "two at-signs"); - assert!(!is_email("a@.co"), "no domain name"); - assert!(!is_email("a@b.c"), "a one-letter top-level domain"); - assert!(!is_email("a@b.c1"), "a top-level domain with a digit"); - assert!(!is_email("a b@c.co"), "a space in the user part"); - assert!(!is_email("a@b c.co"), "a space in the domain"); - assert!(!is_email("a@bc"), "no dot"); - } - - #[test] - fn a_scheme_on_its_own_is_not_an_address() { - assert!(is_url("http://a")); - assert!(!is_url("http://"), "the whole scheme and nothing else"); - assert!(!is_url("https://")); - } - - #[test] - fn a_colour_needs_length_and_digits_at_once() { - assert!(is_color("#FF8800")); - assert!( - !is_color("#GGG"), - "three characters that are not hexadecimal" - ); - assert!(!is_color("rgb(1, 2)"), "missing one component"); - assert!( - !is_color("rgb(a, b, c)"), - "three components that are not numbers" - ); - } - - #[test] - fn a_uuid_needs_shape_and_digits_at_once() { - assert!(is_uuid("6ba7b810-9dad-11d1-80b4-00c04fd430c8")); - assert!(!is_uuid("1-2-3-4-5"), "five groups of the wrong length"); - assert!( - !is_uuid("zzzzzzzz-9dad-11d1-80b4-00c04fd430c8"), - "the right length with characters that are not hexadecimal" - ); - } - - #[test] - fn a_number_needs_shape_and_a_reason_to_be_a_phone() { - assert!(is_phone("+1234567"), "the international prefix is enough"); - assert!(is_phone("(123) 4567"), "the area parenthesis is enough"); - assert!(is_phone("123456789"), "nine digits are enough on their own"); - assert!( - !is_phone("1234567"), - "seven loose digits are not a phone number" - ); - assert!(!is_phone("(123) 456-7890 ñ"), "one letter breaks the shape"); - } - - #[test] - fn nothing_at_all_is_not_an_object() { - assert!( - !is_json(""), - "without a first character there is no delimiter" - ); - assert!(is_json("{}")); - } - - #[test] - fn a_quote_inside_a_string_does_not_close_it() { - assert!(balanced(r#"{"\""}"#), "the escaped quote stays inside"); - assert!(!balanced(r#"{"a": {}"#), "a brace is left unclosed"); - assert!(!balanced(r#"{"a"#), "the string was left open"); - } - - #[test] - fn one_marker_alone_is_not_code() { - assert!(looks_like_code("const a = 1;\n return a;"), "two markers"); - assert!(!looks_like_code("const"), "not even one complete marker"); - assert!( - !looks_like_code("const x"), - "one marker, one line, no indent" - ); - assert!( - !looks_like_code(" const x"), - "one indented marker, but a single line" - ); - assert!( - !looks_like_code("const x\nmás texto"), - "one marker across two lines, neither indented" - ); - } -} +#[path = "kind_borders.rs"] +mod borders; #[cfg(test)] -mod redundancy { - use super::*; - use proptest::prelude::*; - - fn with_the_old_guards(text: &str) -> bool { - let Some((user, host)) = text.split_once('@') else { - return false; - }; - if user.is_empty() || host.len() < 3 || host.contains('@') { - return false; - } - is_email(text) - } - - proptest! { - #[test] - fn the_guards_that_were_here_were_redundant(text in ".{0,40}") { - prop_assert_eq!(is_email(&text), with_the_old_guards(&text)); - } - - #[test] - fn the_guards_were_redundant_for_addresses_too( - user in "[a-z@._%+-]{0,8}", - host in "[a-z@.-]{0,8}", - ) { - let text = format!("{user}@{host}"); - prop_assert_eq!(is_email(&text), with_the_old_guards(&text)); - } - } -} +#[path = "kind_redundancy.rs"] +mod redundancy; #[cfg(test)] -mod inherited_from_2x { - use super::*; - - #[test] - fn the_three_the_rewrite_had_dropped() { - assert_eq!(classify_file("video.flv", false), Kind::Video); - assert_eq!(classify_file("favicon.ico", false), Kind::Image); - assert_eq!(classify_text("mailto:alguien@ejemplo.test"), Kind::Link); - } - - #[test] - fn what_the_rewrite_added_survives() { - for (name, kind) in [ - ("captura.heic", Kind::Image), - ("captura.avif", Kind::Image), - ("pelicula.m4v", Kind::Video), - ("pelicula.mpeg", Kind::Video), - ("audio.opus", Kind::Audio), - ("audio.aiff", Kind::Audio), - ] { - assert_eq!(classify_file(name, false), kind, "«{name}»"); - } - } - - #[test] - fn a_bare_mail_scheme_is_not_a_link() { - assert_eq!(classify_text("mailto:"), Kind::Text); - assert_eq!(classify_text("MAILTO:alguien@ejemplo.test"), Kind::Link); - } - - #[test] - fn an_address_without_the_scheme_is_still_an_address() { - assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); - } - - #[test] - fn a_windows_path_is_classified_by_its_last_segment() { - for (path, kind) in [ - (r"C:\Users\ana\Imagenes\foto.PNG", Kind::Image), - (r"C:\fotos.2024\captura.jpg", Kind::Image), - (r"C:\version.1.2\programa", Kind::File), - (r"\servidor\compartido\clip.MKV", Kind::Video), - (r"C:\sin_extension", Kind::File), - ] { - assert_eq!(classify_file(path, false), kind, "«{path}»"); - } - } - - #[test] - fn a_folder_named_like_a_file_is_still_a_folder() { - assert_eq!(classify_file(r"C:\copias\respaldo.zip", true), Kind::Folder); - assert_eq!(classify_file("fotos.png", true), Kind::Folder); - } - - #[test] - fn a_name_that_is_only_a_dot_has_no_extension() { - assert_eq!(classify_file(".png", false), Kind::Image); - assert_eq!(classify_file(".", false), Kind::File); - assert_eq!(classify_file("", false), Kind::File); - } - - #[test] - fn a_local_windows_path_is_not_a_link() { - assert_eq!(classify_text(r"C:\Users\ana\documento.txt"), Kind::Text); - } -} +#[path = "kind_inherited_from_2x.rs"] +mod inherited_from_2x; diff --git a/crates/cp-core/src/kind_borders.rs b/crates/cp-core/src/kind_borders.rs new file mode 100644 index 00000000..db42988e --- /dev/null +++ b/crates/cp-core/src/kind_borders.rs @@ -0,0 +1,117 @@ +use super::*; + +#[test] +fn every_class_keeps_the_name_the_database_stores() { + for (kind, name) in [ + (Kind::Text, "text"), + (Kind::Code, "code"), + (Kind::Json, "json"), + (Kind::Link, "link"), + (Kind::Email, "email"), + (Kind::Phone, "phone"), + (Kind::Color, "color"), + (Kind::Ip, "ip"), + (Kind::Uuid, "uuid"), + (Kind::Image, "image"), + (Kind::File, "file"), + (Kind::Folder, "folder"), + (Kind::Audio, "audio"), + (Kind::Video, "video"), + ] { + assert_eq!( + kind.as_str(), + name, + "the name travels straight into the kind column" + ); + } +} + +#[test] +fn an_address_needs_every_piece_at_once() { + assert!(is_email("a@b.co")); + assert!(!is_email("@ejemplo.test"), "no user"); + assert!(!is_email("a@b@c.co"), "two at-signs"); + assert!(!is_email("a@.co"), "no domain name"); + assert!(!is_email("a@b.c"), "a one-letter top-level domain"); + assert!(!is_email("a@b.c1"), "a top-level domain with a digit"); + assert!(!is_email("a b@c.co"), "a space in the user part"); + assert!(!is_email("a@b c.co"), "a space in the domain"); + assert!(!is_email("a@bc"), "no dot"); +} + +#[test] +fn a_scheme_on_its_own_is_not_an_address() { + assert!(is_url("http://a")); + assert!(!is_url("http://"), "the whole scheme and nothing else"); + assert!(!is_url("https://")); +} + +#[test] +fn a_colour_needs_length_and_digits_at_once() { + assert!(is_color("#FF8800")); + assert!( + !is_color("#GGG"), + "three characters that are not hexadecimal" + ); + assert!(!is_color("rgb(1, 2)"), "missing one component"); + assert!( + !is_color("rgb(a, b, c)"), + "three components that are not numbers" + ); +} + +#[test] +fn a_uuid_needs_shape_and_digits_at_once() { + assert!(is_uuid("6ba7b810-9dad-11d1-80b4-00c04fd430c8")); + assert!(!is_uuid("1-2-3-4-5"), "five groups of the wrong length"); + assert!( + !is_uuid("zzzzzzzz-9dad-11d1-80b4-00c04fd430c8"), + "the right length with characters that are not hexadecimal" + ); +} + +#[test] +fn a_number_needs_shape_and_a_reason_to_be_a_phone() { + assert!(is_phone("+1234567"), "the international prefix is enough"); + assert!(is_phone("(123) 4567"), "the area parenthesis is enough"); + assert!(is_phone("123456789"), "nine digits are enough on their own"); + assert!( + !is_phone("1234567"), + "seven loose digits are not a phone number" + ); + assert!(!is_phone("(123) 456-7890 ñ"), "one letter breaks the shape"); +} + +#[test] +fn nothing_at_all_is_not_an_object() { + assert!( + !is_json(""), + "without a first character there is no delimiter" + ); + assert!(is_json("{}")); +} + +#[test] +fn a_quote_inside_a_string_does_not_close_it() { + assert!(balanced(r#"{"\""}"#), "the escaped quote stays inside"); + assert!(!balanced(r#"{"a": {}"#), "a brace is left unclosed"); + assert!(!balanced(r#"{"a"#), "the string was left open"); +} + +#[test] +fn one_marker_alone_is_not_code() { + assert!(looks_like_code("const a = 1;\n return a;"), "two markers"); + assert!(!looks_like_code("const"), "not even one complete marker"); + assert!( + !looks_like_code("const x"), + "one marker, one line, no indent" + ); + assert!( + !looks_like_code(" const x"), + "one indented marker, but a single line" + ); + assert!( + !looks_like_code("const x\nmás texto"), + "one marker across two lines, neither indented" + ); +} diff --git a/crates/cp-core/src/kind_inherited_from_2x.rs b/crates/cp-core/src/kind_inherited_from_2x.rs new file mode 100644 index 00000000..54a6b7fb --- /dev/null +++ b/crates/cp-core/src/kind_inherited_from_2x.rs @@ -0,0 +1,64 @@ +use super::*; + +#[test] +fn the_three_the_rewrite_had_dropped() { + assert_eq!(classify_file("video.flv", false), Kind::Video); + assert_eq!(classify_file("favicon.ico", false), Kind::Image); + assert_eq!(classify_text("mailto:alguien@ejemplo.test"), Kind::Link); +} + +#[test] +fn what_the_rewrite_added_survives() { + for (name, kind) in [ + ("captura.heic", Kind::Image), + ("captura.avif", Kind::Image), + ("pelicula.m4v", Kind::Video), + ("pelicula.mpeg", Kind::Video), + ("audio.opus", Kind::Audio), + ("audio.aiff", Kind::Audio), + ] { + assert_eq!(classify_file(name, false), kind, "«{name}»"); + } +} + +#[test] +fn a_bare_mail_scheme_is_not_a_link() { + assert_eq!(classify_text("mailto:"), Kind::Text); + assert_eq!(classify_text("MAILTO:alguien@ejemplo.test"), Kind::Link); +} + +#[test] +fn an_address_without_the_scheme_is_still_an_address() { + assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); +} + +#[test] +fn a_windows_path_is_classified_by_its_last_segment() { + for (path, kind) in [ + (r"C:\Users\ana\Imagenes\foto.PNG", Kind::Image), + (r"C:\fotos.2024\captura.jpg", Kind::Image), + (r"C:\version.1.2\programa", Kind::File), + (r"\servidor\compartido\clip.MKV", Kind::Video), + (r"C:\sin_extension", Kind::File), + ] { + assert_eq!(classify_file(path, false), kind, "«{path}»"); + } +} + +#[test] +fn a_folder_named_like_a_file_is_still_a_folder() { + assert_eq!(classify_file(r"C:\copias\respaldo.zip", true), Kind::Folder); + assert_eq!(classify_file("fotos.png", true), Kind::Folder); +} + +#[test] +fn a_name_that_is_only_a_dot_has_no_extension() { + assert_eq!(classify_file(".png", false), Kind::Image); + assert_eq!(classify_file(".", false), Kind::File); + assert_eq!(classify_file("", false), Kind::File); +} + +#[test] +fn a_local_windows_path_is_not_a_link() { + assert_eq!(classify_text(r"C:\Users\ana\documento.txt"), Kind::Text); +} diff --git a/crates/cp-core/src/kind_redundancy.rs b/crates/cp-core/src/kind_redundancy.rs new file mode 100644 index 00000000..61719955 --- /dev/null +++ b/crates/cp-core/src/kind_redundancy.rs @@ -0,0 +1,28 @@ +use super::*; +use proptest::prelude::*; + +fn with_the_old_guards(text: &str) -> bool { + let Some((user, host)) = text.split_once('@') else { + return false; + }; + if user.is_empty() || host.len() < 3 || host.contains('@') { + return false; + } + is_email(text) +} + +proptest! { + #[test] + fn the_guards_that_were_here_were_redundant(text in ".{0,40}") { + prop_assert_eq!(is_email(&text), with_the_old_guards(&text)); + } + + #[test] + fn the_guards_were_redundant_for_addresses_too( + user in "[a-z@._%+-]{0,8}", + host in "[a-z@.-]{0,8}", + ) { + let text = format!("{user}@{host}"); + prop_assert_eq!(is_email(&text), with_the_old_guards(&text)); + } +} diff --git a/crates/cp-core/src/kind_test.rs b/crates/cp-core/src/kind_test.rs new file mode 100644 index 00000000..d8a60939 --- /dev/null +++ b/crates/cp-core/src/kind_test.rs @@ -0,0 +1,149 @@ +use super::*; + +#[test] +fn every_class_goes_to_its_name_and_back() { + for kind in Kind::ALL { + assert_eq!(Kind::from_name(kind.as_str()), Some(kind)); + } + let mut names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.as_str()).collect(); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), 15, "fifteen classes, fifteen distinct names"); +} + +#[test] +fn a_name_that_is_not_a_class_is_nobody() { + assert_eq!(Kind::from_name("Text"), None, "the name is exact"); + assert_eq!(Kind::from_name("imagen"), None); + assert_eq!(Kind::from_name(""), None); +} + +#[test] +fn the_classes_2x_already_recognised() { + assert_eq!(classify_text("alguien@ejemplo.test"), Kind::Email); + assert_eq!(classify_text("#FF8800"), Kind::Color); + assert_eq!(classify_text("rgb(12, 34, 56)"), Kind::Color); + assert_eq!(classify_text("hsla(120, 50%, 50%, 0.5)"), Kind::Color); + assert_eq!(classify_text("192.168.1.1"), Kind::Ip); + assert_eq!( + classify_text("7ab3f6de-1c4b-4f5e-8a2d-9f0e1b2c3d4e"), + Kind::Uuid + ); + assert_eq!(classify_text("+34 600 123 456"), Kind::Phone); + assert_eq!(classify_text(r#"{"a": 1}"#), Kind::Json); +} + +#[test] +fn a_token_is_its_own_class_and_wins_over_the_rest() { + assert_eq!( + classify_text("ghp_not_a_real_token_for_tests_0000000000"), + Kind::Token + ); + assert_eq!( + classify_text(" sk-not-a-real-key-for-tests-0123456789\n"), + Kind::Token, + "trimmed, like everything else" + ); + assert_eq!(classify_text("sk-corto"), Kind::Text); +} + +#[test] +fn the_classes_2x_had_a_type_for_but_never_assigned() { + assert_eq!(classify_text("https://ejemplo.test/ruta?q=1"), Kind::Link); + assert_eq!(classify_file("cancion.mp3", false), Kind::Audio); + assert_eq!(classify_file("pelicula.MKV", false), Kind::Video); + assert_eq!(classify_file("foto.heic", false), Kind::Image); + assert_eq!(classify_file("Documentos", true), Kind::Folder); + assert_eq!(classify_file("informe.pdf", false), Kind::File); +} + +#[test] +fn code_is_recognised_which_2x_never_did() { + assert_eq!( + classify_text("fn main() {\n println!(\"hola\");\n}"), + Kind::Code + ); + assert_eq!( + classify_text("def suma(a, b):\n return a + b"), + Kind::Code + ); + assert_eq!( + classify_text("SELECT * FROM tabla WHERE id = 1;\nINSERT INTO otra VALUES (1);"), + Kind::Code + ); +} + +#[test] +fn ordinary_prose_is_never_code() { + for prose in [ + "Esto es una frase normal y corriente.", + "Nos vemos mañana si puedes", + "La reunión es a las cinco, en la sala grande", + "return", + ] { + assert_eq!(classify_text(prose), Kind::Text, "«{prose}»"); + } +} + +#[test] +fn something_that_only_looks_like_json_is_not_json() { + for fake in [ + "{esto no es json", + "[1, 2, 3", + "{\"sin cerrar\": \"comilla}", + ] { + assert_ne!(classify_text(fake), Kind::Json, "«{fake}»"); + } + assert_eq!(classify_text("[1, 2, 3]"), Kind::Json); + assert_eq!(classify_text(r#"{"anidado": {"a": [1, 2]}}"#), Kind::Json); +} + +#[test] +fn the_edges_of_each_class() { + assert_eq!(classify_text("#FFF"), Kind::Color); + assert_eq!(classify_text("#FF8800AA"), Kind::Color); + assert_ne!(classify_text("#GGGGGG"), Kind::Color); + assert_ne!(classify_text("256.1.1.1"), Kind::Ip); + assert_ne!(classify_text("1.2.3"), Kind::Ip); + assert_ne!(classify_text("no-es-un-uuid-de-verdad"), Kind::Uuid); + assert_ne!(classify_text("arroba sin@"), Kind::Email); + assert_ne!(classify_text("12345"), Kind::Phone); +} + +#[test] +fn several_lines_never_become_a_single_line_class() { + assert_eq!( + classify_text("alguien@ejemplo.test\notra línea"), + Kind::Text + ); + assert_eq!(classify_text("#FF8800\n#00FF88"), Kind::Text); +} + +#[test] +fn empty_and_blank_are_plain_text() { + assert_eq!(classify_text(""), Kind::Text); + assert_eq!(classify_text(" \n "), Kind::Text); +} + +#[test] +fn every_kind_has_a_stable_name_for_the_database() { + for kind in [ + Kind::Text, + Kind::Code, + Kind::Json, + Kind::Link, + Kind::Email, + Kind::Phone, + Kind::Color, + Kind::Ip, + Kind::Uuid, + Kind::Image, + Kind::File, + Kind::Folder, + Kind::Audio, + Kind::Video, + ] { + assert!(!kind.as_str().is_empty()); + assert_eq!(kind.as_str(), kind.as_str().to_lowercase()); + } +} diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index 9fb9087c..a8df9660 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -109,181 +109,9 @@ impl Failure { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_keystroke_is_the_route_whenever_it_is_allowed() { - assert_eq!(route_for(true, false), Some(Route::Keystroke)); - assert_eq!(route_for(true, true), Some(Route::Keystroke)); - } - - #[test] - fn the_menu_is_the_route_only_when_the_keystroke_is_denied() { - assert_eq!(route_for(false, true), Some(Route::Menu)); - assert_eq!(route_for(false, false), None); - } - - #[test] - fn a_slow_app_gets_the_dozen_tries_the_2x_needed() { - let mut attempt = Attempt::default(); - let mut tries = 1; - while attempt.on_failure(Failure::NotForeground) == Next::Retry { - tries += 1; - } - assert_eq!( - tries, 12, - "Office and Electron take hundreds of ms to come forward" - ); - } - - #[test] - fn an_elevated_target_is_never_retried() { - let mut attempt = Attempt::default(); - assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); - assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); - assert!(Failure::TargetElevated.is_permanent()); - } - - #[test] - fn what_a_second_try_could_fix_is_not_permanent() { - for failure in [ - Failure::NotForeground, - Failure::ForegroundTimeout, - Failure::NoKeyboardFocus, - ] { - assert!(!failure.is_permanent(), "{failure:?}"); - assert_eq!(Attempt::default().on_failure(failure), Next::Retry); - } - } - - #[test] - fn a_target_that_is_gone_is_not_worth_retrying_either() { - assert_eq!( - Attempt::default().on_failure(Failure::TargetGone), - Next::Degrade - ); - } - - #[test] - fn the_clipboard_is_written_before_anything_touches_focus() { - let write = ORDER.iter().position(|p| *p == Phase::WriteClipboard); - let hide = ORDER.iter().position(|p| *p == Phase::HidePanel); - let forward = ORDER.iter().position(|p| *p == Phase::BringTargetForward); - assert_eq!(write, Some(0)); - assert!(write < hide && write < forward); - } - - #[test] - fn focus_is_verified_before_sending_and_never_after() { - let verify = ORDER - .iter() - .position(|p| *p == Phase::VerifyKeyboardFocus) - .unwrap(); - let send = ORDER.iter().position(|p| *p == Phase::Send).unwrap(); - assert!(verify < send); - assert_eq!(send, ORDER.len() - 1); - } - - #[test] - fn what_is_unknown_gets_pasted() { - assert!(!aborts(Focus::Unknown)); - assert!(!aborts(Focus::OnTarget)); - assert!(aborts(Focus::Elsewhere)); - } - - #[test] - fn a_successful_send_is_never_retried() { - let mut attempt = Attempt::default(); - attempt.sending(); - assert_eq!( - attempt.on_failure(Failure::ForegroundTimeout), - Next::Degrade, - "a double paste is worse than none" - ); - } - - #[test] - fn races_are_retried_and_then_given_up() { - let mut attempt = Attempt::default(); - for _ in 0..RACE_RETRIES { - assert_eq!(attempt.on_failure(Failure::ForegroundTimeout), Next::Retry); - } - assert_eq!( - attempt.on_failure(Failure::NoKeyboardFocus), - Next::Degrade, - "the budget is shared between the two races" - ); - } - - #[test] - fn a_denied_send_is_reported_not_retried() { - let mut attempt = Attempt::default(); - assert_eq!( - attempt.on_failure(Failure::SendDenied), - Next::Degrade, - "it's UIPI or TCC: retrying changes nothing" - ); - } - - #[test] - fn secure_input_warns_but_never_aborts() { - assert!(!aborts_on(Warning::SecureInputActive)); - } -} +#[path = "paste_test.rs"] +mod tests; #[cfg(test)] -mod properties { - use super::*; - use proptest::prelude::*; - - fn any_failure() -> impl Strategy { - prop_oneof![ - Just(Failure::ForegroundTimeout), - Just(Failure::NotForeground), - Just(Failure::NoKeyboardFocus), - Just(Failure::TargetGone), - Just(Failure::SendDenied), - ] - } - - proptest! { - #[test] - fn the_retries_are_bounded(failures in prop::collection::vec(any_failure(), 1..40)) { - let mut attempt = Attempt::default(); - let retried = failures - .iter() - .filter(|failure| attempt.on_failure(**failure) == Next::Retry) - .count(); - prop_assert!(retried <= RACE_RETRIES as usize, "retried {retried} times"); - } - - #[test] - fn nothing_is_retried_after_a_send(failures in prop::collection::vec(any_failure(), 1..10)) { - let mut attempt = Attempt::default(); - attempt.sending(); - for failure in failures { - prop_assert_eq!(attempt.on_failure(failure), Next::Degrade); - } - } - - #[test] - fn a_denial_never_becomes_a_retry(before in prop::collection::vec(any_failure(), 0..3)) { - let mut attempt = Attempt::default(); - for failure in before { - attempt.on_failure(failure); - } - prop_assert_eq!(attempt.on_failure(Failure::SendDenied), Next::Degrade); - } - - #[test] - fn the_order_never_puts_the_send_before_a_check(index in 0usize..ORDER.len()) { - if ORDER[index] == Phase::Send { - prop_assert_eq!(index, ORDER.len() - 1); - } - if index > 0 { - prop_assert_ne!(ORDER[index], Phase::WriteClipboard); - } - } - } -} +#[path = "paste_properties.rs"] +mod properties; diff --git a/crates/cp-core/src/paste_as.rs b/crates/cp-core/src/paste_as.rs index 677ac5b1..fe58eb64 100644 --- a/crates/cp-core/src/paste_as.rs +++ b/crates/cp-core/src/paste_as.rs @@ -1362,1227 +1362,5 @@ fn decode_entities(text: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - fn text_of(kind: Kind, text: &str) -> Content<'_> { - Content { - kind: Some(kind), - text: Some(text.into()), - ..Default::default() - } - } - - fn rendered(form: Form, content: &Content) -> String { - match render(form, content).expect("renders") { - Rendered::Text(text) => text, - other => panic!("wasn't text: {other:?}"), - } - } - - #[test] - fn every_form_has_a_stable_name_that_comes_back() { - for form in Form::ALL { - assert_eq!(Form::from_name(form.as_str()), Some(form)); - } - let mut names: Vec<&str> = Form::ALL.iter().map(|form| form.as_str()).collect(); - names.sort_unstable(); - names.dedup(); - assert_eq!(names.len(), Form::ALL.len()); - assert_eq!(Form::from_name("Markdown"), None, "the name is exact"); - } - - #[test] - fn what_was_rendered_can_be_written_back_as_an_item() { - let text = Rendered::Text("hola".into()).into_item(); - assert_eq!(text.formats[0].id, crate::item::SYNTHETIC_TEXT); - assert_eq!( - text.fingerprint(), - crate::item::Item::plain("hola").fingerprint() - ); - let jpeg = Rendered::Jpeg(vec![0xFF, 0xD8, 0xFF]).into_item(); - assert_eq!(jpeg.kind, Some(Kind::Image)); - assert_eq!(jpeg.formats[0].id, crate::item::SYNTHETIC_JPEG); - assert!(matches!( - jpeg.formats[0].payload, - crate::item::Payload::Inline(ref bytes) if bytes == &[0xFF, 0xD8, 0xFF] - )); - let content = Content { - text: Some("x".into()), - ..Default::default() - }; - let owned = Content { - text: Some(String::from("propio").into()), - ..content.clone() - }; - assert_eq!( - owned.text.as_deref(), - Some("propio"), - "the text can be borrowed or owned" - ); - } - - #[test] - fn plain_text_is_offered_as_quote_and_case() { - assert_eq!( - forms_for(&text_of(Kind::Text, "hola")), - vec![Form::TextQuote, Form::TextUpper, Form::TextLower] - ); - assert!( - forms_for(&Content::default()).is_empty(), - "with no text there's nothing to offer" - ); - let two_lines = text_of( - Kind::Text, - "una -otra", - ); - assert_eq!( - forms_for(&two_lines), - vec![ - Form::CodeOneLine, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ], - "joining lines is only offered when there is more than one" - ); - } - - #[test] - fn a_quote_marks_every_line_and_keeps_the_empty_ones() { - let content = text_of( - Kind::Text, - "una - -otra", - ); - assert_eq!( - rendered(Form::TextQuote, &content), - "> una -> -> otra" - ); - } - - #[test] - fn case_forms_change_the_letters_and_nothing_else() { - let content = text_of(Kind::Text, "Hola Ñandú"); - assert_eq!(rendered(Form::TextUpper, &content), "HOLA ÑANDÚ"); - assert_eq!(rendered(Form::TextLower, &content), "hola ñandú"); - } - - #[test] - fn a_file_is_also_offered_by_its_name_alone() { - let content = Content { - kind: Some(Kind::File), - paths: vec![ - "C:\\Users\\Mario\\Documentos\\informe.pdf".into(), - "/tmp/otro.txt".into(), - ], - ..Default::default() - }; - assert_eq!( - rendered(Form::FileName, &content), - "informe.pdf -otro.txt" - ); - let nameless = Content { - kind: Some(Kind::Folder), - paths: vec!["/".into()], - ..Default::default() - }; - assert!( - !forms_for(&nameless).contains(&Form::FileName), - "a path with no name is not offered" - ); - let padded = Content { - kind: Some(Kind::File), - paths: vec!["/tmp/ con espacios.txt ".into()], - ..Default::default() - }; - assert_eq!( - rendered(Form::FileName, &padded), - "con espacios.txt", - "the name arrives clear of spaces" - ); - } - - #[test] - fn every_form_that_is_offered_can_be_rendered() { - let png = tiny_png(); - let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjcC0zIn0.firma"; - let contents = [ - Content { - kind: Some(Kind::Text), - text: Some("hola".into()), - html: Some("hola".into()), - rich: true, - ..Default::default() - }, - text_of(Kind::Json, r#"[{"a": 1}, {"b": 2}]"#), - text_of(Kind::Json, "[1, 2]"), - text_of(Kind::Color, "hsla(10, 20%, 30%, 40%)"), - text_of(Kind::Color, "#FFA500"), - Content { - title: Some("Ejemplo".into()), - ..text_of(Kind::Link, "https://ejemplo.test/a b") - }, - text_of(Kind::Link, "https://localhost/"), - text_of(Kind::Code, " x\n y"), - text_of(Kind::Token, jwt), - text_of(Kind::Token, "ghp_not_a_real_token_for_tests_0000000000"), - Content { - kind: Some(Kind::Image), - image: Some(&png), - ocr: Some("leído"), - paths: vec!["/tmp/a.png".into()], - ..Default::default() - }, - ]; - for content in &contents { - let forms = forms_for(content); - assert!(!forms.is_empty(), "{content:?}"); - for form in forms { - assert!(render(form, content).is_some(), "{form:?} on {content:?}"); - } - } - } - - #[test] - fn a_rich_text_offers_plain_and_markdown_only_with_html() { - let with_html = Content { - kind: Some(Kind::Text), - text: Some("hola".into()), - html: Some("hola".into()), - rich: true, - ..Default::default() - }; - assert_eq!( - forms_for(&with_html), - vec![ - Form::PlainText, - Form::Markdown, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ] - ); - assert_eq!(rendered(Form::Markdown, &with_html), "**hola**"); - let spaced = Content { - text: Some(" hola \n".into()), - ..with_html.clone() - }; - assert_eq!( - rendered(Form::PlainText, &spaced), - " hola \n", - "plain text is delivered exactly as copied, untrimmed" - ); - let rtf_only = Content { - rich: true, - ..text_of(Kind::Text, "hola") - }; - assert_eq!( - forms_for(&rtf_only), - vec![ - Form::PlainText, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ] - ); - } - - #[test] - fn json_is_offered_formatted_minified_by_keys_and_as_a_table() { - let content = text_of( - Kind::Json, - r#"{"b": 1, "a": {"x": [1, 2]}, "c": "hola\tque tal"}"#, - ); - assert_eq!( - forms_for(&content), - vec![ - Form::JsonPretty, - Form::JsonMinified, - Form::JsonKeys, - Form::JsonTable - ] - ); - assert_eq!( - rendered(Form::JsonPretty, &content), - "{\n \"b\": 1,\n \"a\": {\n \"x\": [\n 1,\n 2\n ]\n },\n \"c\": \"hola\\tque tal\"\n}", - "in the order the keys were in" - ); - assert_eq!( - rendered(Form::JsonMinified, &content), - r#"{"b":1,"a":{"x":[1,2]},"c":"hola\tque tal"}"# - ); - assert_eq!(rendered(Form::JsonKeys, &content), "b\na\nc"); - assert_eq!( - rendered(Form::JsonTable, &content), - "b\t1\na\t{\"x\":[1,2]}\nc\thola que tal", - "a tab between key and value; nested ones go minified" - ); - } - - #[test] - fn a_list_of_objects_becomes_a_table_with_a_header() { - let content = text_of( - Kind::Json, - r#"[{"name": "ana", "age": 3}, {"name": "bo", "city": "Lima"}]"#, - ); - assert_eq!(rendered(Form::JsonKeys, &content), "name\nage\ncity"); - assert_eq!( - rendered(Form::JsonTable, &content), - "name\tage\tcity\nana\t3\t\nbo\t\tLima", - "the columns are the union of the keys; what's missing stays empty" - ); - } - - #[test] - fn json_that_is_not_an_object_has_no_keys_and_no_table() { - let content = text_of(Kind::Json, "[1, 2, 3]"); - assert_eq!( - forms_for(&content), - vec![Form::JsonPretty, Form::JsonMinified] - ); - assert_eq!(render(Form::JsonKeys, &content), None); - let broken = text_of(Kind::Json, "{no es json}"); - assert!(forms_for(&broken).is_empty()); - } - - #[test] - fn a_colour_goes_round_the_three_notations() { - let content = text_of(Kind::Color, "#FF8800"); - assert_eq!( - forms_for(&content), - vec![ - Form::ColorHex, - Form::ColorRgb, - Form::ColorHsl, - Form::ColorName - ] - ); - assert_eq!(rendered(Form::ColorHex, &content), "#FF8800"); - assert_eq!(rendered(Form::ColorRgb, &content), "rgb(255, 136, 0)"); - assert_eq!(rendered(Form::ColorHsl, &content), "hsl(32, 100%, 50%)"); - assert_eq!( - rendered(Form::ColorHex, &text_of(Kind::Color, "rgb(255, 136, 0)")), - "#FF8800" - ); - assert_eq!( - rendered(Form::ColorRgb, &text_of(Kind::Color, "hsl(32, 100%, 50%)")), - "rgb(255, 136, 0)" - ); - assert_eq!( - rendered(Form::ColorHex, &text_of(Kind::Color, "#f80")), - "#FF8800" - ); - } - - #[test] - fn a_colour_that_has_a_css_name_is_called_by_it() { - for (text, name) in [ - ("#FFA500", "orange"), - ("#ffa500", "orange"), - ("rgb(255, 0, 0)", "red"), - ("hsl(0, 0%, 50%)", "gray"), - ("#000", "black"), - ("#fff", "white"), - ("#663399", "rebeccapurple"), - ("rgba(255, 165, 0, 1)", "orange"), - ] { - assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); - } - } - - #[test] - fn a_colour_close_to_a_name_borrows_it() { - for (text, name) in [ - ("#FF8800", "darkorange"), - ("#FF9500", "darkorange"), - ("#FFCC00", "gold"), - ("#050505", "black"), - ("#FEFEFE", "white"), - ("#F0F0F0", "whitesmoke"), - ("#0000CC", "mediumblue"), - ] { - assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); - } - } - - #[test] - fn a_colour_far_from_every_name_is_not_offered_one() { - for text in [ - "#123456", "#3B82F6", "#1DB954", "#222222", "#00CC00", "#CC0000", - ] { - let content = text_of(Kind::Color, text); - assert!(!forms_for(&content).contains(&Form::ColorName), "{text}"); - assert_eq!(render(Form::ColorName, &content), None, "{text}"); - } - } - - #[test] - fn a_translucent_colour_has_no_name() { - for text in [ - "#FF880080", - "rgba(255, 165, 0, 0.5)", - "hsla(39, 100%, 50%, 99%)", - ] { - let content = text_of(Kind::Color, text); - assert!(!forms_for(&content).contains(&Form::ColorName), "{text}"); - assert_eq!(render(Form::ColorName, &content), None, "{text}"); - } - } - - #[test] - fn the_spelling_that_wins_is_the_common_one() { - for (text, name) in [ - ("#00FFFF", "cyan"), - ("#FF00FF", "magenta"), - ("#808080", "gray"), - ("#2F4F4F", "darkslategray"), - ("#A9A9A9", "darkgray"), - ] { - assert_eq!(rendered(Form::ColorName, &text_of(Kind::Color, text)), name); - } - } - - #[test] - fn the_named_table_has_no_two_names_for_one_colour() { - let mut values: Vec<[u8; 3]> = NAMED.iter().map(|(_, rgb)| *rgb).collect(); - values.sort_unstable(); - values.dedup(); - assert_eq!(values.len(), NAMED.len()); - for (name, _) in NAMED { - assert!(name.bytes().all(|b| b.is_ascii_lowercase()), "{name}"); - } - } - - #[test] - fn the_distance_is_a_metric_that_weighs_green_most() { - assert_eq!(redmean_squared([0, 0, 0], [0, 0, 0]), 0); - assert_eq!( - redmean_squared([10, 20, 30], [40, 50, 60]), - redmean_squared([40, 50, 60], [10, 20, 30]) - ); - let red = redmean_squared([128, 0, 0], [128 + 15, 0, 0]); - let green = redmean_squared([0, 128, 0], [0, 128 + 15, 0]); - let blue = redmean_squared([0, 0, 128], [0, 0, 128 + 15]); - assert!(green > red && green > blue); - assert!( - redmean_squared([250, 0, 0], [235, 0, 0]) > redmean_squared([10, 0, 0], [25, 0, 0]), - "a difference in red weighs more where there is a lot of red" - ); - assert!( - redmean_squared([250, 0, 0], [250, 0, 15]) < redmean_squared([10, 0, 0], [10, 0, 15]), - "and one in blue weighs more where there is little red" - ); - assert_eq!( - redmean_squared([255, 0, 0], [240, 0, 0]), - (1024 + 495) * 225 - ); - assert_eq!( - redmean_squared([0, 0, 255], [0, 0, 240]), - (1024 + 510) * 225 - ); - assert!(named_color(parse_color("#FF9500").unwrap()).is_some()); - assert_eq!(NAME_WITHIN, 30); - } - - #[test] - fn transparency_survives_every_notation() { - let content = text_of(Kind::Color, "rgba(255, 136, 0, 0.5)"); - assert_eq!(rendered(Form::ColorHex, &content), "#FF880080"); - assert_eq!(rendered(Form::ColorRgb, &content), "rgba(255, 136, 0, 0.5)"); - assert_eq!( - rendered(Form::ColorHsl, &content), - "hsla(32, 100%, 50%, 0.5)" - ); - assert_eq!( - rendered(Form::ColorRgb, &text_of(Kind::Color, "#FF880080")), - "rgba(255, 136, 0, 0.5)" - ); - } - - #[test] - fn grey_has_no_hue_and_the_extremes_do_not_divide_by_zero() { - assert_eq!( - rendered(Form::ColorHsl, &text_of(Kind::Color, "#808080")), - "hsl(0, 0%, 50%)" - ); - assert_eq!( - rendered(Form::ColorHsl, &text_of(Kind::Color, "#000000")), - "hsl(0, 0%, 0%)" - ); - assert_eq!( - rendered(Form::ColorHsl, &text_of(Kind::Color, "#FFFFFF")), - "hsl(0, 0%, 100%)" - ); - assert_eq!( - rendered( - Form::ColorRgb, - &text_of(Kind::Color, "hsl(400, 150%, -10%)") - ), - "rgb(0, 0, 0)", - "out of range it gets clamped, not broken" - ); - } - - #[test] - fn every_sextant_of_the_hue_wheel_round_trips_against_a_reference_table() { - for (hex, hsl) in [ - ("#FF0000", "hsl(0, 100%, 50%)"), - ("#FFFF00", "hsl(60, 100%, 50%)"), - ("#00FF00", "hsl(120, 100%, 50%)"), - ("#00FFFF", "hsl(180, 100%, 50%)"), - ("#0000FF", "hsl(240, 100%, 50%)"), - ("#FF00FF", "hsl(300, 100%, 50%)"), - ("#FF0080", "hsl(330, 100%, 50%)"), - ("#80FF00", "hsl(90, 100%, 50%)"), - ("#00FF80", "hsl(150, 100%, 50%)"), - ("#0080FF", "hsl(210, 100%, 50%)"), - ("#8000FF", "hsl(270, 100%, 50%)"), - ("#3498DB", "hsl(204, 70%, 53%)"), - ("#2ECC71", "hsl(145, 63%, 49%)"), - ("#9B59B6", "hsl(283, 39%, 53%)"), - ("#E67E22", "hsl(28, 80%, 52%)"), - ("#1A0B2E", "hsl(266, 61%, 11%)"), - ("#F0E68C", "hsl(54, 77%, 75%)"), - ] { - assert_eq!( - rendered(Form::ColorHsl, &text_of(Kind::Color, hex)), - hsl, - "{hex}" - ); - } - for (hsl, hex) in [ - ("hsl(0, 100%, 50%)", "#FF0000"), - ("hsl(60, 100%, 50%)", "#FFFF00"), - ("hsl(120, 100%, 50%)", "#00FF00"), - ("hsl(180, 100%, 50%)", "#00FFFF"), - ("hsl(240, 100%, 50%)", "#0000FF"), - ("hsl(300, 100%, 50%)", "#FF00FF"), - ("hsl(330, 100%, 50%)", "#FF0080"), - ("hsl(32, 50%, 50%)", "#BF8440"), - ("hsl(210, 40%, 70%)", "#94B2D1"), - ("hsl(90, 60%, 30%)", "#4D7A1F"), - ("hsl(270, 25%, 80%)", "#CCBFD9"), - ("hsl(15, 80%, 20%)", "#5C1F0A"), - ("hsl(400, 50%, 50%)", "#BF9540"), - ("hsl(-60, 100%, 50%)", "#FF00FF"), - ] { - assert_eq!( - rendered(Form::ColorHex, &text_of(Kind::Color, hsl)), - hex, - "{hsl}" - ); - } - } - - #[test] - fn short_and_eight_digit_hex_expand_digit_by_digit() { - assert_eq!( - rendered(Form::ColorRgb, &text_of(Kind::Color, "#0af")), - "rgb(0, 170, 255)" - ); - assert_eq!( - rendered(Form::ColorRgb, &text_of(Kind::Color, "#FF880099")), - "rgba(255, 136, 0, 0.6)" - ); - assert_eq!( - rendered(Form::ColorRgb, &text_of(Kind::Color, "#12345678")), - "rgba(18, 52, 86, 0.47)" - ); - assert_eq!( - rendered( - Form::ColorHex, - &text_of(Kind::Color, "hsla(200, 50%, 40%, 0.25)") - ), - "#33779940" - ); - } - - #[test] - fn a_colour_that_does_not_parse_offers_nothing_extra() { - assert!(forms_for(&text_of(Kind::Color, "#GGGGGG")).is_empty()); - assert_eq!(parse_color("rgb(1, 2)"), None); - assert_eq!(parse_color("hsl(1, 2, 3, 4, 5)"), None); - assert_eq!(parse_color("#12345"), None); - } - - #[test] - fn a_link_is_offered_as_markdown_domain_and_with_its_title() { - let bare = text_of(Kind::Link, "https://www.ejemplo.test/ruta?x=1#f"); - assert_eq!(forms_for(&bare), vec![Form::LinkMarkdown, Form::LinkDomain]); - assert_eq!( - rendered(Form::LinkMarkdown, &bare), - "[https://www.ejemplo.test/ruta?x=1#f](https://www.ejemplo.test/ruta?x=1#f)" - ); - assert_eq!(rendered(Form::LinkDomain, &bare), "ejemplo.test"); - assert_eq!( - rendered( - Form::LinkDomain, - &text_of(Kind::Link, "https://EJEMPLO.TEST/X") - ), - "ejemplo.test", - "a domain does not distinguish case" - ); - let titled = Content { - title: Some(" Ejemplo, la página ".into()), - ..bare.clone() - }; - assert_eq!(forms_for(&titled).last(), Some(&Form::LinkTitled)); - assert_eq!( - rendered(Form::LinkMarkdown, &titled), - "[Ejemplo, la página](https://www.ejemplo.test/ruta?x=1#f)" - ); - assert_eq!( - rendered(Form::LinkTitled, &titled), - "Ejemplo, la página — https://www.ejemplo.test/ruta?x=1#f" - ); - } - - #[test] - fn the_domain_drops_credentials_port_and_path_but_not_a_subdomain() { - assert_eq!( - domain_of("https://user:pw@api.ejemplo.test:8443/v1"), - Some("api.ejemplo.test") - ); - assert_eq!( - domain_of("ftp://files.ejemplo.test"), - Some("files.ejemplo.test") - ); - assert_eq!( - domain_of("mailto:alguien@ejemplo.test"), - Some("ejemplo.test") - ); - assert_eq!( - domain_of("https://localhost:3000/"), - None, - "no dot means no domain" - ); - assert_eq!(domain_of("https://"), None); - assert_eq!(domain_of(""), None); - assert_eq!( - domain_of("192.168.0.1:8080"), - Some("192.168.0.1"), - "with no scheme, what's before the port is the host" - ); - assert_eq!( - domain_of("https://ejemplo.test:abc/"), - Some("ejemplo.test:abc"), - "a port that is not a number is not trimmed off" - ); - assert_eq!(domain_of("https://ejemplo.test:/"), Some("ejemplo.test:")); - assert_eq!( - domain_of(":ejemplo.test"), - Some(":ejemplo.test"), - "an empty scheme is no scheme" - ); - } - - #[test] - fn code_can_be_one_line_a_block_or_dedented() { - let source = " fn main() {\n println!(\"hola\");\n }\n"; - let content = text_of(Kind::Code, source); - assert_eq!( - forms_for(&content), - vec![Form::CodeOneLine, Form::CodeBlock, Form::CodeDedented] - ); - assert_eq!( - rendered(Form::CodeOneLine, &content), - "fn main() { println!(\"hola\"); }" - ); - assert_eq!( - rendered(Form::CodeBlock, &content), - "```\nfn main() {\n println!(\"hola\");\n }\n```" - ); - assert_eq!( - rendered(Form::CodeDedented, &content), - "fn main() {\n println!(\"hola\");\n}\n", - "what's stripped is only what every line shares, nothing more" - ); - assert_eq!( - dedent("\n a\n\n b\n"), - "\na\n\n b\n", - "empty lines don't count" - ); - } - - #[test] - fn a_token_becomes_a_header_or_a_curl_and_a_jwt_shows_its_claims() { - let opaque = text_of(Kind::Token, "ghp_not_a_real_token_for_tests_0000000000"); - assert_eq!(forms_for(&opaque), vec![Form::TokenHeader, Form::TokenCurl]); - assert_eq!( - rendered(Form::TokenHeader, &opaque), - "Authorization: Bearer ghp_not_a_real_token_for_tests_0000000000" - ); - assert_eq!( - rendered(Form::TokenCurl, &opaque), - "curl -H 'Authorization: Bearer ghp_not_a_real_token_for_tests_0000000000' \"$URL\"" - ); - let jwt = text_of( - Kind::Token, - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjcC0zIiwiZW52Ijoic3RhZ2luZyJ9.firma", - ); - assert_eq!(forms_for(&jwt).last(), Some(&Form::TokenClaims)); - assert_eq!( - rendered(Form::TokenClaims, &jwt), - "{\n \"sub\": \"cp-3\",\n \"env\": \"staging\"\n}" - ); - } - - fn tiny_png() -> Vec { - let mut out = Vec::new(); - let mut image = image::RgbaImage::new(2, 2); - image.put_pixel(0, 0, image::Rgba([255, 0, 0, 255])); - image.put_pixel(1, 0, image::Rgba([0, 0, 0, 0])); - image.put_pixel(0, 1, image::Rgba([0, 255, 0, 255])); - image.put_pixel(1, 1, image::Rgba([0, 0, 255, 128])); - image - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut out)) - .expect("png"); - out - } - - fn solid_png(pixel: [u8; 4]) -> Vec { - let mut out = Vec::new(); - let image = image::RgbaImage::from_pixel(16, 16, image::Rgba(pixel)); - image - .write_with_encoder(image::codecs::png::PngEncoder::new(&mut out)) - .expect("png"); - out - } - - fn middle_of_jpeg(bytes: &[u8]) -> [u8; 3] { - assert_eq!(&bytes[..2], &[0xFF, 0xD8], "JPEG header"); - let back = image::load_from_memory(bytes).expect("reads").to_rgb8(); - back.get_pixel(8, 8).0 - } - - #[test] - fn an_image_is_offered_as_jpeg_and_as_what_was_read_in_it() { - let png = tiny_png(); - let silent = Content { - kind: Some(Kind::Image), - image: Some(&png), - ..Default::default() - }; - assert_eq!(forms_for(&silent), vec![Form::ImageJpeg]); - let read = Content { - ocr: Some(" Pedido 4417 "), - ..silent.clone() - }; - assert_eq!(forms_for(&read), vec![Form::ImageJpeg, Form::ImageOcr]); - assert_eq!(rendered(Form::ImageOcr, &read), "Pedido 4417"); - let blank = Content { - ocr: Some(" "), - ..silent.clone() - }; - assert_eq!(forms_for(&blank), vec![Form::ImageJpeg]); - } - - #[test] - fn the_jpeg_is_a_real_jpeg_with_transparency_laid_on_white() { - let near = |got: [u8; 3], wanted: [u8; 3]| { - got.iter() - .zip(wanted) - .all(|(got, wanted)| got.abs_diff(wanted) <= 6) - }; - let jpeg_of_solid = |pixel: [u8; 4]| { - let png = solid_png(pixel); - let content = Content { - image: Some(&png), - ..Default::default() - }; - match render(Form::ImageJpeg, &content) { - Some(Rendered::Jpeg(bytes)) => middle_of_jpeg(&bytes), - other => panic!("no JPEG came out: {other:?}"), - } - }; - assert!( - near(jpeg_of_solid([200, 30, 30, 255]), [200, 30, 30]), - "opaque untouched" - ); - assert!( - near(jpeg_of_solid([0, 0, 0, 0]), [255, 255, 255]), - "transparent turns white, not black" - ); - assert!( - near(jpeg_of_solid([0, 0, 255, 128]), [127, 127, 255]), - "blue at 50% over white" - ); - assert!( - near(jpeg_of_solid([0, 0, 255, 64]), [191, 191, 255]), - "blue at 25% over white" - ); - assert!( - near(jpeg_of_solid([200, 30, 30, 128]), [227, 142, 142]), - "a muted red at 50% lightens channel by channel" - ); - assert_eq!( - render(Form::ImageJpeg, &text_of(Kind::Image, "no bytes")), - None - ); - let broken = Content { - image: Some(b"no es png"), - ..Default::default() - }; - assert_eq!(render(Form::ImageJpeg, &broken), None); - } - - #[test] - fn a_tiff_from_safari_becomes_a_jpeg_too() { - let mut tiff = Vec::new(); - let image = image::RgbaImage::from_pixel(16, 16, image::Rgba([10, 200, 30, 255])); - image - .write_with_encoder(image::codecs::tiff::TiffEncoder::new(std::io::Cursor::new( - &mut tiff, - ))) - .expect("tiff"); - let content = Content { - kind: Some(Kind::Image), - image: Some(&tiff), - ..Default::default() - }; - let Some(Rendered::Jpeg(bytes)) = render(Form::ImageJpeg, &content) else { - panic!("a TIFF becomes a JPEG too"); - }; - let middle = middle_of_jpeg(&bytes); - assert!( - middle[0].abs_diff(10) <= 6 - && middle[1].abs_diff(200) <= 6 - && middle[2].abs_diff(30) <= 6, - "{middle:?}" - ); - } - - #[test] - fn a_null_cell_is_empty_in_the_table() { - let content = text_of(Kind::Json, r#"[{"a": null, "b": true}]"#); - assert_eq!(rendered(Form::JsonTable, &content), "a\tb\n\ttrue"); - assert_eq!( - rendered(Form::JsonTable, &text_of(Kind::Json, r#"{"k": null}"#)), - "k\t" - ); - } - - #[test] - fn files_are_offered_by_their_paths() { - let content = Content { - kind: Some(Kind::File), - paths: vec!["/tmp/uno.txt".into(), "/tmp/dos.txt".into()], - ..Default::default() - }; - assert_eq!(forms_for(&content), vec![Form::Path, Form::FileName]); - assert_eq!(rendered(Form::Path, &content), "/tmp/uno.txt\n/tmp/dos.txt"); - } - - #[test] - fn a_form_that_does_not_apply_renders_nothing() { - let plain = text_of(Kind::Text, "hola"); - for form in [ - Form::Markdown, - Form::JsonPretty, - Form::ColorHex, - Form::LinkDomain, - Form::TokenClaims, - Form::ImageJpeg, - Form::ImageOcr, - ] { - assert_eq!(render(form, &plain), None, "{form:?}"); - } - } - - #[test] - fn html_from_a_word_processor_becomes_readable_markdown() { - let html = "x\ -

Informe

\ -

Un párrafo con negrita, cursiva y un \ - enlace.

\ -
  • uno
  • dos fuerte
\ -
  1. primero
  2. segundo
\ -

Fin & código x < y

\ - "; - assert_eq!( - markdown_of_html(html), - "## Informe\n\n\ - Un párrafo con **negrita**, *cursiva* y un [enlace](https://ejemplo.test).\n\n\ - - uno\n- dos **fuerte**\n\n\ - 1. primero\n2. segundo\n\n\ - Fin & código `x < y`" - ); - } - - #[test] - fn the_windows_clipboard_header_is_left_out_and_only_the_fragment_counts() { - let html = "Version:0.9\r\nStartHTML:0000000105\r\nEndHTML:0000000250\r\n\ -

solo esto

"; - assert_eq!(markdown_of_html(html), "solo **esto**"); - } - - #[test] - fn preformatted_blocks_keep_their_whitespace_and_nested_lists_indent() { - let html = "
fn main() {\n    hola\n}
  • a
    • b
"; - assert_eq!( - markdown_of_html(html), - "```\nfn main() {\n hola\n}\n```\n\n- a\n - b" - ); - } - - #[test] - fn quotes_images_rules_and_line_breaks() { - let html = "
dicho
\"foto\"
uno
dos"; - assert_eq!( - markdown_of_html(html), - "> dicho\n\n![foto](a.png)\n\n---\n\nuno\ndos" - ); - } - - #[test] - fn whitespace_between_tags_collapses_like_a_browser_would() { - let html = "

\n varias\n palabras juntas \n

\n

otro

"; - assert_eq!(markdown_of_html(html), "varias palabras **juntas**\n\notro"); - } - - #[test] - fn what_is_not_html_at_all_comes_out_as_text() { - assert_eq!(markdown_of_html("2 < 3 y 4 > 1"), "2 < 3 y 4 > 1"); - assert_eq!(markdown_of_html(""), ""); - assert_eq!(markdown_of_html("<"), "<"); - assert_eq!(markdown_of_html("

"), ""); - } - - #[test] - fn entities_of_every_shape_are_decoded_and_the_rest_left_alone() { - assert_eq!( - decode_entities("&<>"' "), - "&<>\"' " - ); - assert_eq!(decode_entities("ABC"), "ABC"); - assert_eq!( - decode_entities("&desconocida; & suelto &;"), - "&desconocida; & suelto &;" - ); - assert_eq!( - decode_entities("�"), - "�", - "outside Unicode" - ); - assert_eq!(decode_entities("áÑ€…"), "áÑ€…"); - assert_eq!(decode_entities("ýü"), "ýü"); - assert_eq!( - decode_entities("→"), - "→", - "unknown named entities are left alone" - ); - } - - #[test] - fn a_script_or_style_is_skipped_until_its_own_closing_tag() { - assert_eq!(markdown_of_html("c"), "c"); - assert_eq!(markdown_of_html("z"), "z"); - assert_eq!( - markdown_of_html("tcuerpo"), - "cuerpo" - ); - } - - #[test] - fn headings_tables_and_lists_keep_their_distance_from_what_follows() { - assert_eq!(markdown_of_html("

t

x"), "# t\n\nx"); - assert_eq!( - markdown_of_html("
ab
"), - "| a | b |\n| --- | --- |" - ); - assert_eq!(markdown_of_html("a
  • b
"), "a\n\n- b"); - assert_eq!(markdown_of_html("
x
"), "```\nx\n```"); - assert_eq!(markdown_of_html("a b"), "**a** b"); - assert_eq!(markdown_of_html("vacío"), "vacío"); - assert_eq!(markdown_of_html("

x

"), "x"); - assert_eq!(markdown_of_html("
q
"), "> q"); - assert_eq!( - markdown_of_html("
x"), - "x", - "a line break at the very start leaves no gap" - ); - assert_eq!(markdown_of_html("
  • a
"), "- a"); - assert_eq!(markdown_of_html("
  1. a
"), "1. a"); - assert_eq!(markdown_of_html("uno

dos"), "uno\ndos"); - assert_eq!(markdown_of_html(" x"), "![](a.png) x"); - assert_eq!(markdown_of_html("
a\n
b"), "```\na\n```\n\nb"); - } - - #[test] - fn a_multibyte_character_right_after_a_tag_does_not_panic() { - assert_eq!(markdown_of_html("

ñandú

"), "ñandú"); - assert_eq!(markdown_of_html("—€"), "**—**€"); - assert_eq!(markdown_of_html("ñ<ñ"), "ñ<ñ"); - } - - #[test] - fn an_angle_bracket_that_never_closes_is_text_and_costs_one_pass() { - let hostile = " b -->x"), "x"); - assert_eq!( - markdown_of_html("b\">t"), - "[t](x)" - ); - assert_eq!(markdown_of_html("

solo esto

"; + assert_eq!(markdown_of_html(html), "solo **esto**"); +} + +#[test] +fn preformatted_blocks_keep_their_whitespace_and_nested_lists_indent() { + let html = "
fn main() {\n    hola\n}
  • a
    • b
"; + assert_eq!( + markdown_of_html(html), + "```\nfn main() {\n hola\n}\n```\n\n- a\n - b" + ); +} + +#[test] +fn quotes_images_rules_and_line_breaks() { + let html = "
dicho
\"foto\"
uno
dos"; + assert_eq!( + markdown_of_html(html), + "> dicho\n\n![foto](a.png)\n\n---\n\nuno\ndos" + ); +} + +#[test] +fn whitespace_between_tags_collapses_like_a_browser_would() { + let html = "

\n varias\n palabras juntas \n

\n

otro

"; + assert_eq!(markdown_of_html(html), "varias palabras **juntas**\n\notro"); +} + +#[test] +fn what_is_not_html_at_all_comes_out_as_text() { + assert_eq!(markdown_of_html("2 < 3 y 4 > 1"), "2 < 3 y 4 > 1"); + assert_eq!(markdown_of_html(""), ""); + assert_eq!(markdown_of_html("<"), "<"); + assert_eq!(markdown_of_html("

"), ""); +} + +#[test] +fn entities_of_every_shape_are_decoded_and_the_rest_left_alone() { + assert_eq!( + decode_entities("&<>"' "), + "&<>\"' " + ); + assert_eq!(decode_entities("ABC"), "ABC"); + assert_eq!( + decode_entities("&desconocida; & suelto &;"), + "&desconocida; & suelto &;" + ); + assert_eq!( + decode_entities("�"), + "�", + "outside Unicode" + ); + assert_eq!(decode_entities("áÑ€…"), "áÑ€…"); + assert_eq!(decode_entities("ýü"), "ýü"); + assert_eq!( + decode_entities("→"), + "→", + "unknown named entities are left alone" + ); +} + +#[test] +fn a_script_or_style_is_skipped_until_its_own_closing_tag() { + assert_eq!(markdown_of_html("c"), "c"); + assert_eq!(markdown_of_html("z"), "z"); + assert_eq!( + markdown_of_html("tcuerpo"), + "cuerpo" + ); +} + +#[test] +fn headings_tables_and_lists_keep_their_distance_from_what_follows() { + assert_eq!(markdown_of_html("

t

x"), "# t\n\nx"); + assert_eq!( + markdown_of_html("
ab
"), + "| a | b |\n| --- | --- |" + ); + assert_eq!(markdown_of_html("a
  • b
"), "a\n\n- b"); + assert_eq!(markdown_of_html("
x
"), "```\nx\n```"); + assert_eq!(markdown_of_html("a b"), "**a** b"); + assert_eq!(markdown_of_html("vacío"), "vacío"); + assert_eq!(markdown_of_html("

x

"), "x"); + assert_eq!(markdown_of_html("
q
"), "> q"); + assert_eq!( + markdown_of_html("
x"), + "x", + "a line break at the very start leaves no gap" + ); + assert_eq!(markdown_of_html("
  • a
"), "- a"); + assert_eq!(markdown_of_html("
  1. a
"), "1. a"); + assert_eq!(markdown_of_html("uno

dos"), "uno\ndos"); + assert_eq!(markdown_of_html(" x"), "![](a.png) x"); + assert_eq!(markdown_of_html("
a\n
b"), "```\na\n```\n\nb"); +} + +#[test] +fn a_multibyte_character_right_after_a_tag_does_not_panic() { + assert_eq!(markdown_of_html("

ñandú

"), "ñandú"); + assert_eq!(markdown_of_html("—€"), "**—**€"); + assert_eq!(markdown_of_html("ñ<ñ"), "ñ<ñ"); +} + +#[test] +fn an_angle_bracket_that_never_closes_is_text_and_costs_one_pass() { + let hostile = " b -->x"), "x"); + assert_eq!( + markdown_of_html("b\">t"), + "[t](x)" + ); + assert_eq!(markdown_of_html("

hola

"; - - fn inline(id: &str, bytes: &[u8]) -> Format { - Format { - id: id.into(), - payload: Payload::Inline(bytes.to_vec()), - } - } - - fn unicode(text: &str) -> Format { - inline("CF_UNICODETEXT", &utf16_of(text)) - } - - #[test] - fn a_word_paragraph_is_rich_with_html_and_plain_text() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![ - inline("Rich Text Format", b"{\\rtf1 hola}"), - inline("HTML Format", WORD_HTML.as_bytes()), - unicode("hola"), - Format { - id: "CF_DIBV5".into(), - payload: Payload::Announced { size: None }, - }, - ], - }; - let content = content_of(&item, None); - assert_eq!(content.text.as_deref(), Some("hola")); - assert_eq!(content.html.as_deref(), Some(WORD_HTML)); - assert!(content.rich); - assert_eq!(content.image, None); - assert_eq!( - forms_for(&content), - vec![ - Form::PlainText, - Form::Markdown, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ] - ); - } - - #[test] - fn the_clipboard_header_of_the_html_never_reaches_the_markdown() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![inline("HTML Format", WORD_HTML.as_bytes()), unicode("hola")], - }; - let markdown = render(Form::Markdown, &content_of(&item, None)); - assert_eq!(markdown, Some(Rendered::Text("**hola**".into()))); - } - - #[test] - fn html_alone_is_enough_to_be_rich() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![inline("HTML Format", b"hola"), unicode("hola")], - }; - let content = content_of(&item, None); - assert!(content.rich); - assert_eq!(content.html.as_deref(), Some("hola")); - } - - #[test] - fn plain_rtf_alone_is_rich_and_a_blob_reads_like_an_inline_payload() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![ - inline("Rich Text Format", b"{\\rtf1 hola}"), - Format { - id: "CF_UNICODETEXT".into(), - payload: Payload::Blob(utf16_of("hola")), - }, - ], - }; - let content = content_of(&item, None); - assert!(content.rich, "el RTF a solas ya es enriquecido"); - assert_eq!( - content.text.as_deref(), - Some("hola"), - "un blob se lee igual que un inline" - ); - } - - #[test] - fn a_rich_text_with_an_image_offers_both_families() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![ - inline("Rich Text Format", b"{\\rtf1 hola}"), - unicode("hola"), - inline("PNG", &[137, 80, 78, 71]), - ], - }; - assert_eq!( - forms_for(&content_of(&item, None)), - vec![ - Form::PlainText, - Form::ImageJpeg, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ], - "un documento con una imagen incrustada se puede pegar como texto o como su imagen" - ); - } - - #[test] - fn an_rtf_only_copy_is_rich_but_has_no_markdown() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![ - inline("Rich Text Format", b"{\\rtf1 hola}"), - unicode("hola"), - ], - }; - let content = content_of(&item, None); - assert!(content.rich); - assert_eq!(content.html, None); - assert_eq!( - forms_for(&content), - vec![ - Form::PlainText, - Form::TextQuote, - Form::TextUpper, - Form::TextLower - ] - ); - } - - #[test] - fn a_drop_becomes_its_paths_and_a_link_carries_no_title() { - let files = Item { - kind: Some(Kind::File), - formats: vec![inline( - "CF_HDROP", - &drop_of(&[r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"]), - )], - }; - let content = content_of(&files, None); - assert_eq!( - content.paths, - vec![r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"] - ); - assert_eq!(content.text, None); - assert_eq!(forms_for(&content), vec![Form::Path, Form::FileName]); - - let link = Item { - kind: Some(Kind::Link), - formats: vec![ - unicode("https://ejemplo.test"), - inline("Chromium internal source URL", b"https://ejemplo.test"), - ], - }; - let content = content_of(&link, None); - assert_eq!(content.title, None); - let forms = forms_for(&content); - assert!(forms.contains(&Form::LinkMarkdown)); - assert!( - !forms.contains(&Form::LinkTitled), - "with no title there is no titled form" - ); - } - - #[test] - fn a_stored_image_and_what_was_read_in_it() { - let item = Item { - kind: Some(Kind::Image), - formats: vec![inline("PNG", &[137, 80, 78, 71])], - }; - let content = content_of(&item, Some("Pedido 4417")); - assert_eq!(content.image, Some(&[137u8, 80, 78, 71][..])); - assert_eq!(content.ocr, Some("Pedido 4417")); - assert!(!content.rich); - assert_eq!(forms_for(&content), vec![Form::ImageJpeg, Form::ImageOcr]); - } - - #[test] - fn a_bitmap_only_copy_is_the_png_we_transcoded_from_it() { - let item = Item { - kind: Some(Kind::Image), - formats: vec![ - Format { - id: "CF_DIBV5".into(), - payload: Payload::Announced { size: Some(4) }, - }, - inline("CF_DIB", &[40, 0, 0, 0]), - inline(SYNTHETIC_IMAGE, &[137, 80, 78, 71]), - ], - }; - let content = content_of(&item, None); - assert_eq!( - content.image, - Some(&[137u8, 80, 78, 71][..]), - "the raw DIB is not offered: the image is the synthetic PNG" - ); - let both = Item { - kind: Some(Kind::Image), - formats: vec![inline(SYNTHETIC_IMAGE, &[1]), inline("PNG", &[2])], - }; - assert_eq!( - content_of(&both, None).image, - Some(&[2u8][..]), - "el PNG de la fuente manda si lo hay" - ); - } - - #[test] - fn what_the_store_wrote_itself_is_read_back_the_same_way() { - let edited = Item::plain("editado a mano"); - assert_eq!( - content_of(&edited, None).text.as_deref(), - Some("editado a mano") - ); - let synthetic = Item { - kind: Some(Kind::Image), - formats: vec![inline(SYNTHETIC_IMAGE, &[1, 2, 3])], - }; - assert_eq!(content_of(&synthetic, None).image, Some(&[1u8, 2, 3][..])); - } - - #[test] - fn the_system_text_wins_over_the_synthetic_one() { - let item = Item { - kind: Some(Kind::Text), - formats: vec![inline(SYNTHETIC_TEXT, b"sintetico"), unicode("del sistema")], - }; - assert_eq!(content_of(&item, None).text.as_deref(), Some("del sistema")); - } - - #[test] - fn bytes_that_are_not_text_do_not_pretend_to_be() { - let lone_surrogate = [0x00, 0xD8, 0x00, 0x00]; - let item = Item { - kind: Some(Kind::Text), - formats: vec![ - inline("CF_UNICODETEXT", &lone_surrogate), - inline("HTML Format", &[0xff, 0xfe, 0x00]), - ], - }; - let content = content_of(&item, None); - assert_eq!(content.text, None); - assert_eq!(content.html, None); - assert!(!content.rich, "unreadable HTML does not make an item rich"); - } -} +#[path = "content_test.rs"] +mod tests; diff --git a/crates/cp-win/src/content_test.rs b/crates/cp-win/src/content_test.rs new file mode 100644 index 00000000..6a859108 --- /dev/null +++ b/crates/cp-win/src/content_test.rs @@ -0,0 +1,254 @@ +use super::*; +use crate::drop::drop_of; +use cp_core::item::Format; +use cp_core::kind::Kind; +use cp_core::paste_as::{Form, Rendered, forms_for, render}; +use cp_win_sys::writing::utf16_of; + +const WORD_HTML: &str = "Version:0.9\r\nStartHTML:0000000105\r\nEndHTML:0000000200\r\nStartFragment:0000000141\r\nEndFragment:0000000164\r\n

hola

"; + +fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } +} + +fn unicode(text: &str) -> Format { + inline("CF_UNICODETEXT", &utf16_of(text)) +} + +#[test] +fn a_word_paragraph_is_rich_with_html_and_plain_text() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + inline("HTML Format", WORD_HTML.as_bytes()), + unicode("hola"), + Format { + id: "CF_DIBV5".into(), + payload: Payload::Announced { size: None }, + }, + ], + }; + let content = content_of(&item, None); + assert_eq!(content.text.as_deref(), Some("hola")); + assert_eq!(content.html.as_deref(), Some(WORD_HTML)); + assert!(content.rich); + assert_eq!(content.image, None); + assert_eq!( + forms_for(&content), + vec![ + Form::PlainText, + Form::Markdown, + Form::TextQuote, + Form::TextUpper, + Form::TextLower + ] + ); +} + +#[test] +fn the_clipboard_header_of_the_html_never_reaches_the_markdown() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline("HTML Format", WORD_HTML.as_bytes()), unicode("hola")], + }; + let markdown = render(Form::Markdown, &content_of(&item, None)); + assert_eq!(markdown, Some(Rendered::Text("**hola**".into()))); +} + +#[test] +fn html_alone_is_enough_to_be_rich() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline("HTML Format", b"hola"), unicode("hola")], + }; + let content = content_of(&item, None); + assert!(content.rich); + assert_eq!(content.html.as_deref(), Some("hola")); +} + +#[test] +fn plain_rtf_alone_is_rich_and_a_blob_reads_like_an_inline_payload() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Blob(utf16_of("hola")), + }, + ], + }; + let content = content_of(&item, None); + assert!(content.rich, "el RTF a solas ya es enriquecido"); + assert_eq!( + content.text.as_deref(), + Some("hola"), + "un blob se lee igual que un inline" + ); +} + +#[test] +fn a_rich_text_with_an_image_offers_both_families() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + unicode("hola"), + inline("PNG", &[137, 80, 78, 71]), + ], + }; + assert_eq!( + forms_for(&content_of(&item, None)), + vec![ + Form::PlainText, + Form::ImageJpeg, + Form::TextQuote, + Form::TextUpper, + Form::TextLower + ], + "un documento con una imagen incrustada se puede pegar como texto o como su imagen" + ); +} + +#[test] +fn an_rtf_only_copy_is_rich_but_has_no_markdown() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("Rich Text Format", b"{\\rtf1 hola}"), + unicode("hola"), + ], + }; + let content = content_of(&item, None); + assert!(content.rich); + assert_eq!(content.html, None); + assert_eq!( + forms_for(&content), + vec![ + Form::PlainText, + Form::TextQuote, + Form::TextUpper, + Form::TextLower + ] + ); +} + +#[test] +fn a_drop_becomes_its_paths_and_a_link_carries_no_title() { + let files = Item { + kind: Some(Kind::File), + formats: vec![inline( + "CF_HDROP", + &drop_of(&[r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"]), + )], + }; + let content = content_of(&files, None); + assert_eq!( + content.paths, + vec![r"C:\Mis Documentos\cp a9.png", r"C:\dos.txt"] + ); + assert_eq!(content.text, None); + assert_eq!(forms_for(&content), vec![Form::Path, Form::FileName]); + + let link = Item { + kind: Some(Kind::Link), + formats: vec![ + unicode("https://ejemplo.test"), + inline("Chromium internal source URL", b"https://ejemplo.test"), + ], + }; + let content = content_of(&link, None); + assert_eq!(content.title, None); + let forms = forms_for(&content); + assert!(forms.contains(&Form::LinkMarkdown)); + assert!( + !forms.contains(&Form::LinkTitled), + "with no title there is no titled form" + ); +} + +#[test] +fn a_stored_image_and_what_was_read_in_it() { + let item = Item { + kind: Some(Kind::Image), + formats: vec![inline("PNG", &[137, 80, 78, 71])], + }; + let content = content_of(&item, Some("Pedido 4417")); + assert_eq!(content.image, Some(&[137u8, 80, 78, 71][..])); + assert_eq!(content.ocr, Some("Pedido 4417")); + assert!(!content.rich); + assert_eq!(forms_for(&content), vec![Form::ImageJpeg, Form::ImageOcr]); +} + +#[test] +fn a_bitmap_only_copy_is_the_png_we_transcoded_from_it() { + let item = Item { + kind: Some(Kind::Image), + formats: vec![ + Format { + id: "CF_DIBV5".into(), + payload: Payload::Announced { size: Some(4) }, + }, + inline("CF_DIB", &[40, 0, 0, 0]), + inline(SYNTHETIC_IMAGE, &[137, 80, 78, 71]), + ], + }; + let content = content_of(&item, None); + assert_eq!( + content.image, + Some(&[137u8, 80, 78, 71][..]), + "the raw DIB is not offered: the image is the synthetic PNG" + ); + let both = Item { + kind: Some(Kind::Image), + formats: vec![inline(SYNTHETIC_IMAGE, &[1]), inline("PNG", &[2])], + }; + assert_eq!( + content_of(&both, None).image, + Some(&[2u8][..]), + "el PNG de la fuente manda si lo hay" + ); +} + +#[test] +fn what_the_store_wrote_itself_is_read_back_the_same_way() { + let edited = Item::plain("editado a mano"); + assert_eq!( + content_of(&edited, None).text.as_deref(), + Some("editado a mano") + ); + let synthetic = Item { + kind: Some(Kind::Image), + formats: vec![inline(SYNTHETIC_IMAGE, &[1, 2, 3])], + }; + assert_eq!(content_of(&synthetic, None).image, Some(&[1u8, 2, 3][..])); +} + +#[test] +fn the_system_text_wins_over_the_synthetic_one() { + let item = Item { + kind: Some(Kind::Text), + formats: vec![inline(SYNTHETIC_TEXT, b"sintetico"), unicode("del sistema")], + }; + assert_eq!(content_of(&item, None).text.as_deref(), Some("del sistema")); +} + +#[test] +fn bytes_that_are_not_text_do_not_pretend_to_be() { + let lone_surrogate = [0x00, 0xD8, 0x00, 0x00]; + let item = Item { + kind: Some(Kind::Text), + formats: vec![ + inline("CF_UNICODETEXT", &lone_surrogate), + inline("HTML Format", &[0xff, 0xfe, 0x00]), + ], + }; + let content = content_of(&item, None); + assert_eq!(content.text, None); + assert_eq!(content.html, None); + assert!(!content.rich, "unreadable HTML does not make an item rich"); +} diff --git a/crates/cp-win/src/drop.rs b/crates/cp-win/src/drop.rs index a68524f2..28bf9948 100644 --- a/crates/cp-win/src/drop.rs +++ b/crates/cp-win/src/drop.rs @@ -61,49 +61,5 @@ pub(crate) fn ansi_drop_of(paths: &[&str]) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn every_copied_path_is_read_not_just_the_first() { - let paths = [r"C:\uno.txt", r"C:\dos.txt", r"C:\una carpeta"]; - assert_eq!(paths_in(&drop_of(&paths)), paths); - } - - #[test] - fn a_legacy_ansi_drop_is_read_too() { - let paths = [r"C:\uno.txt", r"C:\dos.txt"]; - assert_eq!(paths_in(&ansi_drop_of(&paths)), paths); - } - - #[test] - fn a_single_path_comes_back_alone() { - assert_eq!(paths_in(&drop_of(&[r"C:\solo.png"])), [r"C:\solo.png"]); - } - - #[test] - fn nonsense_is_not_a_drop() { - assert!(paths_in(&[]).is_empty()); - assert!(paths_in(&[0, 0, 0]).is_empty()); - assert!(paths_in(&u32::MAX.to_le_bytes()).is_empty()); - } - - #[test] - fn a_path_with_accents_and_spaces_survives() { - let paths = [r"C:\My Documents\report ñ.pdf"]; - assert_eq!(paths_in(&drop_of(&paths)), paths); - } - - #[test] - fn the_drop_we_build_is_the_shape_the_shell_reads() { - let drop = drop_of(&[r"C:\a.txt"]); - assert_eq!( - &drop[..4], - &20u32.to_le_bytes(), - "los nombres empiezan tras la cabecera" - ); - assert_eq!(drop[16], 1, "y van en UTF-16"); - assert_eq!(&drop[drop.len() - 4..], &[0, 0, 0, 0], "doble terminador"); - assert!(paths_in(&drop_of::<&str>(&[])).is_empty()); - } -} +#[path = "drop_test.rs"] +mod tests; diff --git a/crates/cp-win/src/drop_test.rs b/crates/cp-win/src/drop_test.rs new file mode 100644 index 00000000..8bedfa1a --- /dev/null +++ b/crates/cp-win/src/drop_test.rs @@ -0,0 +1,44 @@ +use super::*; + +#[test] +fn every_copied_path_is_read_not_just_the_first() { + let paths = [r"C:\uno.txt", r"C:\dos.txt", r"C:\una carpeta"]; + assert_eq!(paths_in(&drop_of(&paths)), paths); +} + +#[test] +fn a_legacy_ansi_drop_is_read_too() { + let paths = [r"C:\uno.txt", r"C:\dos.txt"]; + assert_eq!(paths_in(&ansi_drop_of(&paths)), paths); +} + +#[test] +fn a_single_path_comes_back_alone() { + assert_eq!(paths_in(&drop_of(&[r"C:\solo.png"])), [r"C:\solo.png"]); +} + +#[test] +fn nonsense_is_not_a_drop() { + assert!(paths_in(&[]).is_empty()); + assert!(paths_in(&[0, 0, 0]).is_empty()); + assert!(paths_in(&u32::MAX.to_le_bytes()).is_empty()); +} + +#[test] +fn a_path_with_accents_and_spaces_survives() { + let paths = [r"C:\My Documents\report ñ.pdf"]; + assert_eq!(paths_in(&drop_of(&paths)), paths); +} + +#[test] +fn the_drop_we_build_is_the_shape_the_shell_reads() { + let drop = drop_of(&[r"C:\a.txt"]); + assert_eq!( + &drop[..4], + &20u32.to_le_bytes(), + "los nombres empiezan tras la cabecera" + ); + assert_eq!(drop[16], 1, "y van en UTF-16"); + assert_eq!(&drop[drop.len() - 4..], &[0, 0, 0, 0], "doble terminador"); + assert!(paths_in(&drop_of::<&str>(&[])).is_empty()); +} diff --git a/crates/cp-win/src/formats.rs b/crates/cp-win/src/formats.rs index 616c80ef..50e1c215 100644 --- a/crates/cp-win/src/formats.rs +++ b/crates/cp-win/src/formats.rs @@ -77,308 +77,5 @@ pub const CATALOG: Catalog = Catalog { }; #[cfg(test)] -mod tests { - use super::CATALOG; - use cp_core::formats::{Family, Refusal, Take}; - - const WORD: &[&str] = &[ - "DataObject", - "Object Descriptor", - "Rich Text Format", - "HTML Format", - "CF_TEXT", - "CF_UNICODETEXT", - "CF_ENHMETAFILE", - "CF_METAFILEPICT", - "Embed Source", - "Native", - "OwnerLink", - "Link Source", - "Link Source Descriptor", - "ObjectLink", - "Ole Private Data", - "CF_LOCALE", - "CF_OEMTEXT", - ]; - - const EXCEL: &[&str] = &[ - "DataObject", - "CF_ENHMETAFILE", - "CF_METAFILEPICT", - "CF_BITMAP", - "Biff12", - "Biff8", - "Biff5", - "CF_SYLK", - "CF_DIF", - "XML Spreadsheet", - "HTML Format", - "CF_UNICODETEXT", - "CF_TEXT", - "Csv", - "Rich Text Format", - "Embed Source", - "Native", - "OwnerLink", - "Object Descriptor", - "Link Source", - "Link Source Descriptor", - "Link", - "ExcludeClipboardContentFromMonitorProcessing", - "ObjectLink", - "Ole Private Data", - "CF_LOCALE", - "CF_OEMTEXT", - "CF_DIB", - "CF_DIBV5", - ]; - - const FIREFOX: &[&str] = &[ - "DataObject", - "text/html", - "HTML Format", - "text/_moz_htmlcontext", - "text/_moz_htmlinfo", - "CF_UNICODETEXT", - "CF_TEXT", - "text/x-moz-url-priv", - "Ole Private Data", - "CF_LOCALE", - "CF_OEMTEXT", - ]; - - const CHROME: &[&str] = &[ - "HTML Format", - "CF_UNICODETEXT", - "Chromium internal source RFH token", - "Chromium internal source URL", - "CF_LOCALE", - "CF_TEXT", - "CF_OEMTEXT", - ]; - - const EXPLORER: &[&str] = &[ - "DataObject", - "Shell IDList Array", - "DataObjectAttributes", - "DataObjectAttributesRequiringElevation", - "Shell Object Offsets", - "Preferred DropEffect", - "AsyncFlag", - "CF_HDROP", - "FileName", - "FileContents", - "FileNameW", - "FileGroupDescriptorW", - "ZoneIdentifier", - "Ole Private Data", - ]; - - const SNIP: &[&str] = &[ - "DataObject", - "CF_BITMAP", - "PNG", - "CanUploadToCloudClipboard", - "CanIncludeInClipboardHistory", - "Ole Private Data", - "CF_DIB", - "CF_DIBV5", - ]; - - const TERMINAL: &[&str] = &["CF_UNICODETEXT", "CF_LOCALE", "CF_TEXT", "CF_OEMTEXT"]; - - fn kept(offered: &[&str]) -> Vec<&'static str> { - offered - .iter() - .filter(|id| CATALOG.decide(id) == Take::Payload) - .filter(|id| !CATALOG.costlier_twin(id, offered)) - .map(|id| { - CATALOG - .wanted - .iter() - .find(|one| **one == CATALOG.canonical(id)) - .copied() - .unwrap_or("?") - }) - .collect() - } - - #[test] - fn word_keeps_its_styles_and_leaves_the_ole_object_alone() { - assert_eq!(CATALOG.classify(WORD), Some(Family::Text)); - let kept = kept(WORD); - assert!(kept.contains(&"Rich Text Format"), "{kept:?}"); - assert!(kept.contains(&"HTML Format"), "{kept:?}"); - assert!(kept.contains(&"CF_UNICODETEXT"), "{kept:?}"); - for heavy in ["Embed Source", "Native", "CF_ENHMETAFILE"] { - assert_eq!(CATALOG.decide(heavy), Take::Presence, "{heavy}"); - } - } - - #[test] - fn a_spreadsheet_is_text_and_not_a_picture_of_itself() { - assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); - } - - #[test] - fn excel_asks_not_to_be_recorded_and_says_so_by_name() { - assert_eq!( - CATALOG.refusal(EXCEL), - Some(Refusal::Marked( - "ExcludeClipboardContentFromMonitorProcessing" - )) - ); - assert_eq!(CATALOG.refusal(WORD), None, "Word does not ask for it"); - } - - #[test] - fn a_password_manager_that_says_zero_is_obeyed() { - assert_eq!( - CATALOG.declines("CanIncludeInClipboardHistory", &[0, 0, 0, 0]), - Some(Refusal::Declined("CanIncludeInClipboardHistory")) - ); - assert_eq!( - CATALOG.declines("CanIncludeInClipboardHistory", &[1, 0, 0, 0]), - None, - "the screen capture says yes and it is stored" - ); - } - - #[test] - fn a_browser_selection_is_not_stored_twice() { - assert_eq!(CATALOG.classify(FIREFOX), Some(Family::Text)); - assert!(CATALOG.costlier_twin("text/html", FIREFOX)); - assert!(!CATALOG.costlier_twin("HTML Format", FIREFOX)); - } - - #[test] - fn the_ansi_halves_of_the_text_are_never_kept() { - for source in [WORD, EXCEL, FIREFOX, CHROME, TERMINAL] { - for degraded in ["CF_TEXT", "CF_OEMTEXT"] { - assert!( - CATALOG.costlier_twin(degraded, source), - "{degraded} would be kept while the Unicode could be" - ); - } - assert!(!CATALOG.costlier_twin("CF_UNICODETEXT", source)); - } - } - - #[test] - fn a_screen_capture_costs_its_png_and_not_its_bitmap() { - assert_eq!(CATALOG.classify(SNIP), Some(Family::Image)); - assert_eq!(CATALOG.preferred_image(SNIP), Some("PNG")); - assert!(CATALOG.costlier_twin("CF_DIB", SNIP)); - assert!(CATALOG.costlier_twin("CF_DIBV5", SNIP)); - assert!(!CATALOG.costlier_twin("PNG", SNIP)); - } - - #[test] - fn without_a_png_the_bitmap_with_alpha_wins() { - let paint = ["DataObject", "CF_BITMAP", "CF_DIB", "CF_DIBV5"]; - assert_eq!(CATALOG.preferred_image(&paint), Some("CF_DIBV5")); - assert!(CATALOG.costlier_twin("CF_DIB", &paint)); - assert_eq!(CATALOG.classify(&paint), Some(Family::Image)); - } - - #[test] - fn the_explorer_needs_only_the_paths_and_the_effect() { - assert_eq!(CATALOG.classify(EXPLORER), Some(Family::Files)); - let kept = kept(EXPLORER); - assert!(kept.contains(&"CF_HDROP"), "{kept:?}"); - assert!(kept.contains(&"Preferred DropEffect"), "{kept:?}"); - assert_eq!(kept.len(), 2, "and nothing else: {kept:?}"); - for partial in ["FileName", "FileNameW"] { - assert_eq!(CATALOG.decide(partial), Take::Presence, "{partial}"); - } - } - - #[test] - fn virtual_files_are_noted_and_never_asked_for_through_the_catalog() { - for virtualised in ["FileGroupDescriptorW", "FileContents"] { - assert_eq!(CATALOG.decide(virtualised), Take::Presence); - } - assert_eq!( - CATALOG.classify(&["FileGroupDescriptorW", "FileContents"]), - None, - "without CF_HDROP the catalog sees no files: the capture asks for them over OLE when there is no drop" - ); - } - - #[test] - fn the_plainest_copy_there_is_still_an_item() { - assert_eq!(CATALOG.classify(TERMINAL), Some(Family::Text)); - assert_eq!(kept(TERMINAL), vec!["CF_UNICODETEXT"]); - } - - #[test] - fn a_browser_copy_keeps_where_it_came_from() { - assert_eq!( - CATALOG.decide("Chromium internal source URL"), - Take::Payload - ); - assert_eq!( - CATALOG.decide("Chromium internal source RFH token"), - Take::Presence, - "the internal token is nobody's context" - ); - } - - #[test] - fn the_unknown_is_only_noted() { - for unknown in ["ApplicationXYZ", "", "invented/type"] { - assert_eq!(CATALOG.decide(unknown), Take::Presence); - } - } - - #[test] - fn every_measured_source_gets_a_class() { - for (name, source) in [ - ("Word", WORD), - ("Excel", EXCEL), - ("Firefox", FIREFOX), - ("Chrome", CHROME), - ("Explorer", EXPLORER), - ("Snip", SNIP), - ("Terminal", TERMINAL), - ] { - assert!(CATALOG.classify(source).is_some(), "{name}"); - } - } - - #[test] - fn nothing_is_both_wanted_and_wasteful() { - for id in CATALOG.wanted { - assert!(!CATALOG.wasteful.contains(id), "«{id}» is on both lists"); - } - } - - #[test] - fn everything_that_names_a_family_can_be_copied() { - for id in CATALOG.text.iter().chain(CATALOG.files) { - assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); - } - for id in CATALOG.images_by_preference { - assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); - } - } - - #[test] - fn every_marker_that_is_read_by_value_can_be_read_at_all() { - for id in CATALOG.denied_when_zero { - assert_eq!( - CATALOG.decide(id), - Take::Payload, - "«{id}» decides by its value and nobody would ask for its bytes" - ); - } - } - - #[test] - fn the_preferred_of_each_group_is_one_that_gets_copied() { - for group in CATALOG.equivalents { - let first = group.first().expect("an empty group breaks no ties"); - assert_eq!(CATALOG.decide(first), Take::Payload, "«{first}»"); - } - } -} +#[path = "formats_test.rs"] +mod tests; diff --git a/crates/cp-win/src/formats_test.rs b/crates/cp-win/src/formats_test.rs new file mode 100644 index 00000000..51bde78d --- /dev/null +++ b/crates/cp-win/src/formats_test.rs @@ -0,0 +1,303 @@ +use super::CATALOG; +use cp_core::formats::{Family, Refusal, Take}; + +const WORD: &[&str] = &[ + "DataObject", + "Object Descriptor", + "Rich Text Format", + "HTML Format", + "CF_TEXT", + "CF_UNICODETEXT", + "CF_ENHMETAFILE", + "CF_METAFILEPICT", + "Embed Source", + "Native", + "OwnerLink", + "Link Source", + "Link Source Descriptor", + "ObjectLink", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", +]; + +const EXCEL: &[&str] = &[ + "DataObject", + "CF_ENHMETAFILE", + "CF_METAFILEPICT", + "CF_BITMAP", + "Biff12", + "Biff8", + "Biff5", + "CF_SYLK", + "CF_DIF", + "XML Spreadsheet", + "HTML Format", + "CF_UNICODETEXT", + "CF_TEXT", + "Csv", + "Rich Text Format", + "Embed Source", + "Native", + "OwnerLink", + "Object Descriptor", + "Link Source", + "Link Source Descriptor", + "Link", + "ExcludeClipboardContentFromMonitorProcessing", + "ObjectLink", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", + "CF_DIB", + "CF_DIBV5", +]; + +const FIREFOX: &[&str] = &[ + "DataObject", + "text/html", + "HTML Format", + "text/_moz_htmlcontext", + "text/_moz_htmlinfo", + "CF_UNICODETEXT", + "CF_TEXT", + "text/x-moz-url-priv", + "Ole Private Data", + "CF_LOCALE", + "CF_OEMTEXT", +]; + +const CHROME: &[&str] = &[ + "HTML Format", + "CF_UNICODETEXT", + "Chromium internal source RFH token", + "Chromium internal source URL", + "CF_LOCALE", + "CF_TEXT", + "CF_OEMTEXT", +]; + +const EXPLORER: &[&str] = &[ + "DataObject", + "Shell IDList Array", + "DataObjectAttributes", + "DataObjectAttributesRequiringElevation", + "Shell Object Offsets", + "Preferred DropEffect", + "AsyncFlag", + "CF_HDROP", + "FileName", + "FileContents", + "FileNameW", + "FileGroupDescriptorW", + "ZoneIdentifier", + "Ole Private Data", +]; + +const SNIP: &[&str] = &[ + "DataObject", + "CF_BITMAP", + "PNG", + "CanUploadToCloudClipboard", + "CanIncludeInClipboardHistory", + "Ole Private Data", + "CF_DIB", + "CF_DIBV5", +]; + +const TERMINAL: &[&str] = &["CF_UNICODETEXT", "CF_LOCALE", "CF_TEXT", "CF_OEMTEXT"]; + +fn kept(offered: &[&str]) -> Vec<&'static str> { + offered + .iter() + .filter(|id| CATALOG.decide(id) == Take::Payload) + .filter(|id| !CATALOG.costlier_twin(id, offered)) + .map(|id| { + CATALOG + .wanted + .iter() + .find(|one| **one == CATALOG.canonical(id)) + .copied() + .unwrap_or("?") + }) + .collect() +} + +#[test] +fn word_keeps_its_styles_and_leaves_the_ole_object_alone() { + assert_eq!(CATALOG.classify(WORD), Some(Family::Text)); + let kept = kept(WORD); + assert!(kept.contains(&"Rich Text Format"), "{kept:?}"); + assert!(kept.contains(&"HTML Format"), "{kept:?}"); + assert!(kept.contains(&"CF_UNICODETEXT"), "{kept:?}"); + for heavy in ["Embed Source", "Native", "CF_ENHMETAFILE"] { + assert_eq!(CATALOG.decide(heavy), Take::Presence, "{heavy}"); + } +} + +#[test] +fn a_spreadsheet_is_text_and_not_a_picture_of_itself() { + assert_eq!(CATALOG.classify(EXCEL), Some(Family::Text)); +} + +#[test] +fn excel_asks_not_to_be_recorded_and_says_so_by_name() { + assert_eq!( + CATALOG.refusal(EXCEL), + Some(Refusal::Marked( + "ExcludeClipboardContentFromMonitorProcessing" + )) + ); + assert_eq!(CATALOG.refusal(WORD), None, "Word does not ask for it"); +} + +#[test] +fn a_password_manager_that_says_zero_is_obeyed() { + assert_eq!( + CATALOG.declines("CanIncludeInClipboardHistory", &[0, 0, 0, 0]), + Some(Refusal::Declined("CanIncludeInClipboardHistory")) + ); + assert_eq!( + CATALOG.declines("CanIncludeInClipboardHistory", &[1, 0, 0, 0]), + None, + "the screen capture says yes and it is stored" + ); +} + +#[test] +fn a_browser_selection_is_not_stored_twice() { + assert_eq!(CATALOG.classify(FIREFOX), Some(Family::Text)); + assert!(CATALOG.costlier_twin("text/html", FIREFOX)); + assert!(!CATALOG.costlier_twin("HTML Format", FIREFOX)); +} + +#[test] +fn the_ansi_halves_of_the_text_are_never_kept() { + for source in [WORD, EXCEL, FIREFOX, CHROME, TERMINAL] { + for degraded in ["CF_TEXT", "CF_OEMTEXT"] { + assert!( + CATALOG.costlier_twin(degraded, source), + "{degraded} would be kept while the Unicode could be" + ); + } + assert!(!CATALOG.costlier_twin("CF_UNICODETEXT", source)); + } +} + +#[test] +fn a_screen_capture_costs_its_png_and_not_its_bitmap() { + assert_eq!(CATALOG.classify(SNIP), Some(Family::Image)); + assert_eq!(CATALOG.preferred_image(SNIP), Some("PNG")); + assert!(CATALOG.costlier_twin("CF_DIB", SNIP)); + assert!(CATALOG.costlier_twin("CF_DIBV5", SNIP)); + assert!(!CATALOG.costlier_twin("PNG", SNIP)); +} + +#[test] +fn without_a_png_the_bitmap_with_alpha_wins() { + let paint = ["DataObject", "CF_BITMAP", "CF_DIB", "CF_DIBV5"]; + assert_eq!(CATALOG.preferred_image(&paint), Some("CF_DIBV5")); + assert!(CATALOG.costlier_twin("CF_DIB", &paint)); + assert_eq!(CATALOG.classify(&paint), Some(Family::Image)); +} + +#[test] +fn the_explorer_needs_only_the_paths_and_the_effect() { + assert_eq!(CATALOG.classify(EXPLORER), Some(Family::Files)); + let kept = kept(EXPLORER); + assert!(kept.contains(&"CF_HDROP"), "{kept:?}"); + assert!(kept.contains(&"Preferred DropEffect"), "{kept:?}"); + assert_eq!(kept.len(), 2, "and nothing else: {kept:?}"); + for partial in ["FileName", "FileNameW"] { + assert_eq!(CATALOG.decide(partial), Take::Presence, "{partial}"); + } +} + +#[test] +fn virtual_files_are_noted_and_never_asked_for_through_the_catalog() { + for virtualised in ["FileGroupDescriptorW", "FileContents"] { + assert_eq!(CATALOG.decide(virtualised), Take::Presence); + } + assert_eq!( + CATALOG.classify(&["FileGroupDescriptorW", "FileContents"]), + None, + "without CF_HDROP the catalog sees no files: the capture asks for them over OLE when there is no drop" + ); +} + +#[test] +fn the_plainest_copy_there_is_still_an_item() { + assert_eq!(CATALOG.classify(TERMINAL), Some(Family::Text)); + assert_eq!(kept(TERMINAL), vec!["CF_UNICODETEXT"]); +} + +#[test] +fn a_browser_copy_keeps_where_it_came_from() { + assert_eq!( + CATALOG.decide("Chromium internal source URL"), + Take::Payload + ); + assert_eq!( + CATALOG.decide("Chromium internal source RFH token"), + Take::Presence, + "the internal token is nobody's context" + ); +} + +#[test] +fn the_unknown_is_only_noted() { + for unknown in ["ApplicationXYZ", "", "invented/type"] { + assert_eq!(CATALOG.decide(unknown), Take::Presence); + } +} + +#[test] +fn every_measured_source_gets_a_class() { + for (name, source) in [ + ("Word", WORD), + ("Excel", EXCEL), + ("Firefox", FIREFOX), + ("Chrome", CHROME), + ("Explorer", EXPLORER), + ("Snip", SNIP), + ("Terminal", TERMINAL), + ] { + assert!(CATALOG.classify(source).is_some(), "{name}"); + } +} + +#[test] +fn nothing_is_both_wanted_and_wasteful() { + for id in CATALOG.wanted { + assert!(!CATALOG.wasteful.contains(id), "«{id}» is on both lists"); + } +} + +#[test] +fn everything_that_names_a_family_can_be_copied() { + for id in CATALOG.text.iter().chain(CATALOG.files) { + assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); + } + for id in CATALOG.images_by_preference { + assert_eq!(CATALOG.decide(id), Take::Payload, "«{id}»"); + } +} + +#[test] +fn every_marker_that_is_read_by_value_can_be_read_at_all() { + for id in CATALOG.denied_when_zero { + assert_eq!( + CATALOG.decide(id), + Take::Payload, + "«{id}» decides by its value and nobody would ask for its bytes" + ); + } +} + +#[test] +fn the_preferred_of_each_group_is_one_that_gets_copied() { + for group in CATALOG.equivalents { + let first = group.first().expect("an empty group breaks no ties"); + assert_eq!(CATALOG.decide(first), Take::Payload, "«{first}»"); + } +} diff --git a/crates/cp-win/src/paste.rs b/crates/cp-win/src/paste.rs index 19d171b1..b3d746a2 100644 --- a/crates/cp-win/src/paste.rs +++ b/crates/cp-win/src/paste.rs @@ -83,57 +83,5 @@ fn focus_of(target: &Target) -> Focus { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_clipboard_is_written_before_anything_touches_the_focus() { - assert_eq!(phases().first(), Some(&Phase::WriteClipboard)); - assert_eq!(phases().last(), Some(&Phase::Send)); - } - - #[test] - fn the_order_is_the_one_the_core_fixes() { - assert_eq!(phases(), ORDER); - } - - #[test] - fn an_elevated_target_degrades_without_retrying() { - let mut attempt = Attempt::default(); - assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); - } - - #[test] - fn the_waits_are_shorter_than_the_paste_they_guard() { - assert!(SETTLE <= MODIFIERS_GO); - assert!(MODIFIERS_GO < Duration::from_millis(500)); - assert!(u128::from(TARGET_ANSWERS_MS) < MODIFIERS_GO.as_millis() * 2); - } - - #[test] - fn a_target_that_is_us_is_on_target() { - let Some(window) = frontmost::foreground() else { - return; - }; - let target = Target { - window, - focus: None, - thread: 0, - }; - assert_eq!(focus_of(&target), Focus::OnTarget); - } - - #[test] - fn a_window_that_is_not_in_front_is_elsewhere() { - let Some(front) = frontmost::foreground() else { - return; - }; - let other = Target { - window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), - focus: None, - thread: 0, - }; - assert_ne!(other.window, front); - assert_eq!(focus_of(&other), Focus::Elsewhere); - } -} +#[path = "paste_test.rs"] +mod tests; diff --git a/crates/cp-win/src/paste_test.rs b/crates/cp-win/src/paste_test.rs new file mode 100644 index 00000000..54cf82f4 --- /dev/null +++ b/crates/cp-win/src/paste_test.rs @@ -0,0 +1,52 @@ +use super::*; + +#[test] +fn the_clipboard_is_written_before_anything_touches_the_focus() { + assert_eq!(phases().first(), Some(&Phase::WriteClipboard)); + assert_eq!(phases().last(), Some(&Phase::Send)); +} + +#[test] +fn the_order_is_the_one_the_core_fixes() { + assert_eq!(phases(), ORDER); +} + +#[test] +fn an_elevated_target_degrades_without_retrying() { + let mut attempt = Attempt::default(); + assert_eq!(attempt.on_failure(Failure::TargetElevated), Next::Degrade); +} + +#[test] +fn the_waits_are_shorter_than_the_paste_they_guard() { + assert!(SETTLE <= MODIFIERS_GO); + assert!(MODIFIERS_GO < Duration::from_millis(500)); + assert!(u128::from(TARGET_ANSWERS_MS) < MODIFIERS_GO.as_millis() * 2); +} + +#[test] +fn a_target_that_is_us_is_on_target() { + let Some(window) = frontmost::foreground() else { + return; + }; + let target = Target { + window, + focus: None, + thread: 0, + }; + assert_eq!(focus_of(&target), Focus::OnTarget); +} + +#[test] +fn a_window_that_is_not_in_front_is_elsewhere() { + let Some(front) = frontmost::foreground() else { + return; + }; + let other = Target { + window: windows::Win32::Foundation::HWND(std::ptr::dangling_mut()), + focus: None, + thread: 0, + }; + assert_ne!(other.window, front); + assert_eq!(focus_of(&other), Focus::Elsewhere); +} diff --git a/crates/cp-win/src/restore.rs b/crates/cp-win/src/restore.rs index 5c731df2..1eabdfbe 100644 --- a/crates/cp-win/src/restore.rs +++ b/crates/cp-win/src/restore.rs @@ -114,187 +114,5 @@ fn image_and_bitmap(name: &str, encoded: &[u8], raw: Option>) -> Vec<(u3 } #[cfg(test)] -mod tests { - use super::*; - use cp_core::item::Format; - - fn inline(id: &str, bytes: &[u8]) -> Format { - Format { - id: id.into(), - payload: Payload::Inline(bytes.to_vec()), - } - } - - #[test] - fn a_synthetic_text_goes_back_as_the_unicode_the_system_wants() { - let written = writable(SYNTHETIC_TEXT, b"hello"); - assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hello"))]); - } - - #[test] - fn a_synthetic_image_goes_back_as_both_a_png_and_a_bitmap() { - let png = image_bytes(); - let written = writable(SYNTHETIC_IMAGE, &png); - assert_eq!(written.len(), 2, "the modern one and the classic one"); - assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); - assert!( - written.iter().any(|(_, bytes)| bytes == &png), - "the PNG travels intact" - ); - } - - #[test] - fn a_rendered_jpeg_goes_back_as_jfif_and_a_bitmap() { - let jpeg = jpeg_bytes(); - let written = writable(SYNTHETIC_JPEG, &jpeg); - assert_eq!(written.len(), 2, "the JPEG and the classic one"); - let (jfif, _) = written - .iter() - .find(|(_, bytes)| bytes == &jpeg) - .expect("the JPEG travels intact"); - assert_eq!(cp_win_sys::formats::name_of(*jfif), JFIF); - assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); - } - - #[test] - fn a_jpeg_that_does_not_decode_still_goes_back_as_itself() { - let written = writable(SYNTHETIC_JPEG, b"this is not a jpeg"); - assert_eq!(written.len(), 1, "no bitmap, but the JPEG is not lost"); - assert!(written.iter().all(|(id, _)| *id != CF_DIBV5)); - } - - #[test] - fn a_name_the_system_does_not_know_is_registered_not_dropped() { - let written = writable("Rich Text Format", b"{\\rtf1}"); - assert_eq!(written.len(), 1); - assert!(written[0].0 >= 0xC000); - } - - #[test] - fn an_image_that_needs_two_ids_is_not_an_incomplete_restore() { - let png = image_bytes(); - let ids = writable(SYNTHETIC_IMAGE, &png); - assert_eq!(ids.len(), 2, "one stored format goes out by two routes"); - let item = Item { - kind: None, - formats: vec![Format { - id: SYNTHETIC_IMAGE.into(), - payload: Payload::Inline(png), - }], - }; - assert_eq!(item.formats.len(), 1, "and it is still a single format"); - } - - #[test] - fn virtual_files_never_go_back_as_themselves_but_as_files_on_disk() { - use crate::virtual_files::{DESCRIPTOR, contents_id, descriptor_of}; - let item = Item { - kind: Some(cp_core::kind::Kind::File), - formats: vec![ - inline(DESCRIPTOR, &descriptor_of(&[("note.txt", Some(4), false)])), - Format { - id: "FileContents".into(), - payload: Payload::Announced { size: None }, - }, - inline(&contents_id(0), b"hello"), - ], - }; - let (owned, delivered) = pasted_files(&item); - assert_eq!( - delivered, 3, - "the three virtual formats go out through the drop" - ); - assert_eq!(owned.len(), 2, "CF_HDROP and the effect"); - assert_eq!(owned[0].0, CF_HDROP); - let paths = crate::drop::paths_in(&owned[0].1); - assert_eq!(paths.len(), 1); - assert!(paths[0].ends_with("note.txt"), "{}", paths[0]); - assert_eq!(std::fs::read(&paths[0]).expect("on disk"), b"hello"); - assert_eq!( - owned[1].1, - COPY.to_le_bytes(), - "pasting copies, it does not move" - ); - let folder = std::path::Path::new(&paths[0]).parent().expect("a folder"); - std::fs::remove_dir_all(folder).ok(); - } - - #[test] - fn an_item_without_virtual_files_pastes_no_files() { - assert_eq!(pasted_files(&Item::plain("hello")), (Vec::new(), 0)); - } - - #[test] - fn nothing_readable_is_nothing_to_write() { - let item = Item { - kind: None, - formats: vec![Format { - id: "CF_UNICODETEXT".into(), - payload: Payload::Announced { size: Some(10) }, - }], - }; - assert!(item.formats.iter().all(|one| payload_of(one).is_none())); - } - - #[test] - fn pasting_as_plain_text_is_a_rendered_form_written_like_any_item() { - use cp_core::paste_as::{Form, render}; - let item = Item { - kind: Some(cp_core::kind::Kind::Text), - formats: vec![ - inline("HTML Format", b"hello"), - inline("CF_UNICODETEXT", &utf16_of("hello")), - ], - }; - let plain = render(Form::PlainText, &crate::content::content_of(&item, None)) - .expect("there is text") - .into_item(); - let written: Vec<(u32, Vec)> = plain - .formats - .iter() - .filter_map(|one| payload_of(one).map(|bytes| writable(&one.id, bytes))) - .flatten() - .collect(); - assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hello"))]); - assert_eq!(item.formats.len(), 2, "the stored item does not change"); - let only_image = Item { - kind: Some(cp_core::kind::Kind::Image), - formats: vec![inline(PNG, &[1, 2, 3])], - }; - assert_eq!( - render( - Form::PlainText, - &crate::content::content_of(&only_image, None) - ), - None, - "with no plain text there is no plain form to offer" - ); - } - - fn jpeg_bytes() -> Vec { - let mut out = Vec::new(); - image::load_from_memory(&image_bytes()) - .expect("png") - .to_rgb8() - .write_to( - &mut std::io::Cursor::new(&mut out), - image::ImageFormat::Jpeg, - ) - .expect("jpeg"); - out - } - - fn image_bytes() -> Vec { - let mut dib = Vec::new(); - dib.extend_from_slice(&40u32.to_le_bytes()); - dib.extend_from_slice(&2i32.to_le_bytes()); - dib.extend_from_slice(&2i32.to_le_bytes()); - dib.extend_from_slice(&1u16.to_le_bytes()); - dib.extend_from_slice(&32u16.to_le_bytes()); - dib.extend_from_slice(&0u32.to_le_bytes()); - dib.extend_from_slice(&16u32.to_le_bytes()); - dib.resize(40, 0); - dib.extend_from_slice(&[0x20, 0x60, 0xA0, 0xFF].repeat(4)); - dib::to_png(&dib).expect("png") - } -} +#[path = "restore_test.rs"] +mod tests; diff --git a/crates/cp-win/src/restore_test.rs b/crates/cp-win/src/restore_test.rs new file mode 100644 index 00000000..358911c7 --- /dev/null +++ b/crates/cp-win/src/restore_test.rs @@ -0,0 +1,182 @@ +use super::*; +use cp_core::item::Format; + +fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } +} + +#[test] +fn a_synthetic_text_goes_back_as_the_unicode_the_system_wants() { + let written = writable(SYNTHETIC_TEXT, b"hello"); + assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hello"))]); +} + +#[test] +fn a_synthetic_image_goes_back_as_both_a_png_and_a_bitmap() { + let png = image_bytes(); + let written = writable(SYNTHETIC_IMAGE, &png); + assert_eq!(written.len(), 2, "the modern one and the classic one"); + assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); + assert!( + written.iter().any(|(_, bytes)| bytes == &png), + "the PNG travels intact" + ); +} + +#[test] +fn a_rendered_jpeg_goes_back_as_jfif_and_a_bitmap() { + let jpeg = jpeg_bytes(); + let written = writable(SYNTHETIC_JPEG, &jpeg); + assert_eq!(written.len(), 2, "the JPEG and the classic one"); + let (jfif, _) = written + .iter() + .find(|(_, bytes)| bytes == &jpeg) + .expect("the JPEG travels intact"); + assert_eq!(cp_win_sys::formats::name_of(*jfif), JFIF); + assert!(written.iter().any(|(id, _)| *id == CF_DIBV5)); +} + +#[test] +fn a_jpeg_that_does_not_decode_still_goes_back_as_itself() { + let written = writable(SYNTHETIC_JPEG, b"this is not a jpeg"); + assert_eq!(written.len(), 1, "no bitmap, but the JPEG is not lost"); + assert!(written.iter().all(|(id, _)| *id != CF_DIBV5)); +} + +#[test] +fn a_name_the_system_does_not_know_is_registered_not_dropped() { + let written = writable("Rich Text Format", b"{\\rtf1}"); + assert_eq!(written.len(), 1); + assert!(written[0].0 >= 0xC000); +} + +#[test] +fn an_image_that_needs_two_ids_is_not_an_incomplete_restore() { + let png = image_bytes(); + let ids = writable(SYNTHETIC_IMAGE, &png); + assert_eq!(ids.len(), 2, "one stored format goes out by two routes"); + let item = Item { + kind: None, + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(png), + }], + }; + assert_eq!(item.formats.len(), 1, "and it is still a single format"); +} + +#[test] +fn virtual_files_never_go_back_as_themselves_but_as_files_on_disk() { + use crate::virtual_files::{DESCRIPTOR, contents_id, descriptor_of}; + let item = Item { + kind: Some(cp_core::kind::Kind::File), + formats: vec![ + inline(DESCRIPTOR, &descriptor_of(&[("note.txt", Some(4), false)])), + Format { + id: "FileContents".into(), + payload: Payload::Announced { size: None }, + }, + inline(&contents_id(0), b"hello"), + ], + }; + let (owned, delivered) = pasted_files(&item); + assert_eq!( + delivered, 3, + "the three virtual formats go out through the drop" + ); + assert_eq!(owned.len(), 2, "CF_HDROP and the effect"); + assert_eq!(owned[0].0, CF_HDROP); + let paths = crate::drop::paths_in(&owned[0].1); + assert_eq!(paths.len(), 1); + assert!(paths[0].ends_with("note.txt"), "{}", paths[0]); + assert_eq!(std::fs::read(&paths[0]).expect("on disk"), b"hello"); + assert_eq!( + owned[1].1, + COPY.to_le_bytes(), + "pasting copies, it does not move" + ); + let folder = std::path::Path::new(&paths[0]).parent().expect("a folder"); + std::fs::remove_dir_all(folder).ok(); +} + +#[test] +fn an_item_without_virtual_files_pastes_no_files() { + assert_eq!(pasted_files(&Item::plain("hello")), (Vec::new(), 0)); +} + +#[test] +fn nothing_readable_is_nothing_to_write() { + let item = Item { + kind: None, + formats: vec![Format { + id: "CF_UNICODETEXT".into(), + payload: Payload::Announced { size: Some(10) }, + }], + }; + assert!(item.formats.iter().all(|one| payload_of(one).is_none())); +} + +#[test] +fn pasting_as_plain_text_is_a_rendered_form_written_like_any_item() { + use cp_core::paste_as::{Form, render}; + let item = Item { + kind: Some(cp_core::kind::Kind::Text), + formats: vec![ + inline("HTML Format", b"hello"), + inline("CF_UNICODETEXT", &utf16_of("hello")), + ], + }; + let plain = render(Form::PlainText, &crate::content::content_of(&item, None)) + .expect("there is text") + .into_item(); + let written: Vec<(u32, Vec)> = plain + .formats + .iter() + .filter_map(|one| payload_of(one).map(|bytes| writable(&one.id, bytes))) + .flatten() + .collect(); + assert_eq!(written, vec![(CF_UNICODETEXT, utf16_of("hello"))]); + assert_eq!(item.formats.len(), 2, "the stored item does not change"); + let only_image = Item { + kind: Some(cp_core::kind::Kind::Image), + formats: vec![inline(PNG, &[1, 2, 3])], + }; + assert_eq!( + render( + Form::PlainText, + &crate::content::content_of(&only_image, None) + ), + None, + "with no plain text there is no plain form to offer" + ); +} + +fn jpeg_bytes() -> Vec { + let mut out = Vec::new(); + image::load_from_memory(&image_bytes()) + .expect("png") + .to_rgb8() + .write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .expect("jpeg"); + out +} + +fn image_bytes() -> Vec { + let mut dib = Vec::new(); + dib.extend_from_slice(&40u32.to_le_bytes()); + dib.extend_from_slice(&2i32.to_le_bytes()); + dib.extend_from_slice(&2i32.to_le_bytes()); + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&32u16.to_le_bytes()); + dib.extend_from_slice(&0u32.to_le_bytes()); + dib.extend_from_slice(&16u32.to_le_bytes()); + dib.resize(40, 0); + dib.extend_from_slice(&[0x20, 0x60, 0xA0, 0xFF].repeat(4)); + dib::to_png(&dib).expect("png") +} diff --git a/crates/cp-win/src/transfer.rs b/crates/cp-win/src/transfer.rs index 09b57a62..8381182b 100644 --- a/crates/cp-win/src/transfer.rs +++ b/crates/cp-win/src/transfer.rs @@ -24,59 +24,5 @@ pub fn transfer(value: &[u8]) -> Transfer { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn what_the_explorer_really_offers_when_copying() { - assert_eq!(transfer(&5u32.to_le_bytes()), Transfer::Copy); - assert_ne!( - 5u32, COPY, - "el valor medido no es COPY a secas, y compararlo por igualdad falla" - ); - } - - #[test] - fn a_cut_is_recognised_by_its_bit() { - assert_eq!(transfer(&MOVE.to_le_bytes()), Transfer::Move); - assert_eq!(transfer(&(MOVE | LINK).to_le_bytes()), Transfer::Move); - } - - #[test] - fn a_cut_wins_over_a_copy_when_both_bits_are_set() { - assert_eq!(transfer(&(COPY | MOVE).to_le_bytes()), Transfer::Move); - assert_eq!(transfer(&7u32.to_le_bytes()), Transfer::Move); - } - - #[test] - fn a_plain_copy_is_a_copy() { - assert_eq!(transfer(©.to_le_bytes()), Transfer::Copy); - assert_eq!(transfer(&LINK.to_le_bytes()), Transfer::Copy); - } - - #[test] - fn what_cannot_be_read_says_nothing() { - assert_eq!(transfer(&[]), Transfer::Unsaid); - assert_eq!(transfer(&[2]), Transfer::Unsaid); - assert_eq!(transfer(&[2, 0, 0]), Transfer::Unsaid); - assert_eq!(transfer(&0u32.to_le_bytes()), Transfer::Unsaid); - } - - #[test] - fn the_four_bytes_are_one_little_endian_number() { - assert_eq!(transfer(&[0, 0, 0, 2]), Transfer::Unsaid); - assert_eq!(transfer(&[2, 0, 0, 0]), Transfer::Move); - } - - #[test] - fn a_longer_value_is_read_by_its_first_four_bytes() { - assert_eq!(transfer(&[5, 0, 0, 0, 9, 9, 9]), Transfer::Copy); - assert_eq!(transfer(&[2, 0, 0, 0, 9, 9, 9]), Transfer::Move); - } - - #[test] - fn the_high_bits_decide_nothing() { - assert_eq!(transfer(&0x8000_0000u32.to_le_bytes()), Transfer::Unsaid); - assert_eq!(transfer(&0x8000_0002u32.to_le_bytes()), Transfer::Move); - } -} +#[path = "transfer_test.rs"] +mod tests; diff --git a/crates/cp-win/src/transfer_test.rs b/crates/cp-win/src/transfer_test.rs new file mode 100644 index 00000000..ecab00ce --- /dev/null +++ b/crates/cp-win/src/transfer_test.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +fn what_the_explorer_really_offers_when_copying() { + assert_eq!(transfer(&5u32.to_le_bytes()), Transfer::Copy); + assert_ne!( + 5u32, COPY, + "el valor medido no es COPY a secas, y compararlo por igualdad falla" + ); +} + +#[test] +fn a_cut_is_recognised_by_its_bit() { + assert_eq!(transfer(&MOVE.to_le_bytes()), Transfer::Move); + assert_eq!(transfer(&(MOVE | LINK).to_le_bytes()), Transfer::Move); +} + +#[test] +fn a_cut_wins_over_a_copy_when_both_bits_are_set() { + assert_eq!(transfer(&(COPY | MOVE).to_le_bytes()), Transfer::Move); + assert_eq!(transfer(&7u32.to_le_bytes()), Transfer::Move); +} + +#[test] +fn a_plain_copy_is_a_copy() { + assert_eq!(transfer(©.to_le_bytes()), Transfer::Copy); + assert_eq!(transfer(&LINK.to_le_bytes()), Transfer::Copy); +} + +#[test] +fn what_cannot_be_read_says_nothing() { + assert_eq!(transfer(&[]), Transfer::Unsaid); + assert_eq!(transfer(&[2]), Transfer::Unsaid); + assert_eq!(transfer(&[2, 0, 0]), Transfer::Unsaid); + assert_eq!(transfer(&0u32.to_le_bytes()), Transfer::Unsaid); +} + +#[test] +fn the_four_bytes_are_one_little_endian_number() { + assert_eq!(transfer(&[0, 0, 0, 2]), Transfer::Unsaid); + assert_eq!(transfer(&[2, 0, 0, 0]), Transfer::Move); +} + +#[test] +fn a_longer_value_is_read_by_its_first_four_bytes() { + assert_eq!(transfer(&[5, 0, 0, 0, 9, 9, 9]), Transfer::Copy); + assert_eq!(transfer(&[2, 0, 0, 0, 9, 9, 9]), Transfer::Move); +} + +#[test] +fn the_high_bits_decide_nothing() { + assert_eq!(transfer(&0x8000_0000u32.to_le_bytes()), Transfer::Unsaid); + assert_eq!(transfer(&0x8000_0002u32.to_le_bytes()), Transfer::Move); +} diff --git a/crates/cp-win/src/virtual_files.rs b/crates/cp-win/src/virtual_files.rs index 08144941..0eb7c949 100644 --- a/crates/cp-win/src/virtual_files.rs +++ b/crates/cp-win/src/virtual_files.rs @@ -181,255 +181,5 @@ pub(crate) fn descriptor_of(entries: &[(&str, Option, bool)]) -> Vec { } #[cfg(test)] -mod tests { - use super::*; - use cp_core::kind::Kind; - - fn inline(id: &str, bytes: &[u8]) -> Format { - Format { - id: id.into(), - payload: Payload::Inline(bytes.to_vec()), - } - } - - fn scratch(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("cp-virtual-{}-{name}", std::process::id())); - std::fs::remove_dir_all(&dir).ok(); - dir - } - - #[test] - fn a_descriptor_names_its_files_with_their_sizes_and_folders() { - let described = described_in(&descriptor_of(&[ - ("report.pdf", Some(1234), false), - ("attachments", None, true), - ("attachments\\photo copy.jpg", Some(5 << 32 | 7), false), - ])); - assert_eq!( - described, - vec![ - Described { - name: "report.pdf".into(), - size: Some(1234), - is_dir: false - }, - Described { - name: "attachments".into(), - size: None, - is_dir: true - }, - Described { - name: "attachments\\photo copy.jpg".into(), - size: Some(5 << 32 | 7), - is_dir: false - }, - ] - ); - } - - #[test] - fn the_name_runs_to_the_last_of_its_two_hundred_and_sixty_units() { - let longest = "n".repeat(NAME_UNITS - 1); - let described = described_in(&descriptor_of(&[(&longest, None, false)])); - assert_eq!(described[0].name, longest); - let accented = "ñ".repeat(200); - let described = described_in(&descriptor_of(&[(&accented, None, false)])); - assert_eq!(described[0].name, accented); - } - - #[test] - fn a_folder_needs_both_the_flag_and_the_attribute() { - let mut only_flag = descriptor_of(&[("x", None, true)]); - only_flag[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4].copy_from_slice(&0x20u32.to_le_bytes()); - assert!( - !described_in(&only_flag)[0].is_dir, - "FD_ATTRIBUTES on an ordinary file" - ); - let mut only_attribute = descriptor_of(&[("x", Some(1), false)]); - only_attribute[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] - .copy_from_slice(&FILE_ATTRIBUTE_DIRECTORY.to_le_bytes()); - assert!( - !described_in(&only_attribute)[0].is_dir, - "without FD_ATTRIBUTES the attribute counts for nothing" - ); - let mut both_bits = descriptor_of(&[("x", None, true)]); - both_bits[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] - .copy_from_slice(&(FILE_ATTRIBUTE_DIRECTORY | 0x20).to_le_bytes()); - assert!(described_in(&both_bits)[0].is_dir); - let unsized_dir = described_in(&descriptor_of(&[("x", None, true)])); - assert_eq!(unsized_dir[0].size, None); - } - - #[test] - fn the_size_is_read_from_its_own_two_words() { - let mut swapped = descriptor_of(&[("x", Some(7), false)]); - swapped[4 + SIZE_HIGH_AT..4 + SIZE_HIGH_AT + 4].copy_from_slice(&3u32.to_le_bytes()); - swapped[4 + SIZE_LOW_AT..4 + SIZE_LOW_AT + 4].copy_from_slice(&0u32.to_le_bytes()); - assert_eq!(described_in(&swapped)[0].size, Some(3 << 32)); - assert_eq!( - described_in(&descriptor_of(&[("x", Some(u32::MAX as u64 + 2), false)]))[0].size, - Some(u32::MAX as u64 + 2) - ); - } - - #[test] - fn a_truncated_or_lying_descriptor_yields_what_it_really_holds() { - assert!(described_in(&[]).is_empty()); - assert!(described_in(&[0, 0, 0]).is_empty()); - let mut lying = descriptor_of(&[("a.txt", None, false)]); - lying[..4].copy_from_slice(&9u32.to_le_bytes()); - assert_eq!(described_in(&lying).len(), 1, "says nine, brings one"); - let cut = &descriptor_of(&[("a.txt", None, false)])[..300]; - assert!(described_in(cut).is_empty()); - } - - #[test] - fn the_contents_ids_count_from_the_descriptor() { - assert_eq!(contents_id(0), "FileContents#0"); - assert_eq!(index_of("FileContents#12"), Some(12)); - assert_eq!(index_of("FileContents"), None); - assert_eq!(index_of("FileContents#x"), None); - assert!(is_virtual(DESCRIPTOR)); - assert!(is_virtual(CONTENTS)); - assert!(is_virtual("FileContents#3")); - assert!(!is_virtual("CF_HDROP")); - } - - #[test] - fn virtual_files_are_offered_only_when_there_is_no_real_drop() { - assert!(offered_without_a_drop(&[ - DESCRIPTOR, - CONTENTS, - "RenPrivateItem" - ])); - assert!( - !offered_without_a_drop(&[DESCRIPTOR, CONTENTS, "CF_HDROP"]), - "Explorer offers both: the ones on disk win" - ); - assert!(!offered_without_a_drop(&[DESCRIPTOR])); - assert!(!offered_without_a_drop(&[CONTENTS])); - } - - #[test] - fn a_name_from_the_descriptor_never_escapes_its_folder() { - assert_eq!( - relative_path_of("report.pdf"), - Some(PathBuf::from("report.pdf")) - ); - assert_eq!( - relative_path_of("folder\\sub/note.txt"), - Some(PathBuf::from("folder").join("sub").join("note.txt")) - ); - for bad in [ - "", - "..\\x.txt", - "a\\..\\b", - ".", - "C:\\x.txt", - "\\\\srv\\share", - "bad.txt", - "trailing. ", - "tab\tname", - ] { - assert_eq!(relative_path_of(bad), None, "{bad:?}"); - } - } - - #[test] - fn materializing_writes_each_file_under_the_item_and_hands_back_the_top_paths() { - let root = scratch("write"); - let item = Item { - kind: Some(Kind::File), - formats: vec![ - inline( - DESCRIPTOR, - &descriptor_of(&[ - ("report.pdf", Some(4), false), - ("attachments", None, true), - ("attachments\\note.txt", Some(3), false), - ]), - ), - inline(&contents_id(0), b"%PDF"), - inline(&contents_id(2), b"hello"), - ], - }; - let out = materialize(&item, &root).expect("it was written"); - assert!(out.complete); - assert_eq!( - out.paths.len(), - 2, - "report.pdf and attachments: {:?}", - out.paths - ); - assert_eq!(std::fs::read(&out.paths[0]).expect("pdf"), b"%PDF"); - assert!(out.paths[1].is_dir()); - assert_eq!( - std::fs::read(out.paths[1].join("note.txt")).expect("note"), - b"hello" - ); - assert!( - out.paths[0].starts_with(&root), - "{:?} outside {root:?}", - out.paths[0] - ); - let again = materialize(&item, &root).expect("paste twice"); - assert_eq!( - again.paths, out.paths, - "the same fingerprint, the same folder" - ); - std::fs::remove_dir_all(&root).ok(); - } - - #[test] - fn what_never_arrived_or_cannot_be_named_leaves_the_paste_incomplete() { - let root = scratch("incomplete"); - let item = Item { - kind: Some(Kind::File), - formats: vec![ - inline( - DESCRIPTOR, - &descriptor_of(&[ - ("big.iso", Some(1 << 40), false), - ("..\\outside.txt", Some(1), false), - ("ok.txt", Some(2), false), - ]), - ), - Format { - id: contents_id(0), - payload: Payload::TooBig { size: 1 << 40 }, - }, - inline(&contents_id(1), b"x"), - inline(&contents_id(2), b"ok"), - ], - }; - let out = materialize(&item, &root).expect("something was written"); - assert!(!out.complete); - assert_eq!(out.paths.len(), 1); - assert!(out.paths[0].ends_with("ok.txt")); - assert!( - !root.join("outside.txt").exists(), - "nothing escapes the folder" - ); - std::fs::remove_dir_all(&root).ok(); - } - - #[test] - fn an_item_without_virtual_files_has_nothing_to_materialize() { - let root = scratch("none"); - assert_eq!(materialize(&Item::plain("hello"), &root), None); - let empty = Item { - kind: None, - formats: vec![inline(DESCRIPTOR, &descriptor_of(&[]))], - }; - assert_eq!(materialize(&empty, &root), None); - let announced = Item { - kind: None, - formats: vec![Format { - id: DESCRIPTOR.into(), - payload: Payload::Announced { size: Some(596) }, - }], - }; - assert_eq!(materialize(&announced, &root), None); - assert!(!root.exists(), "with no files, no folder is created"); - } -} +#[path = "virtual_files_test.rs"] +mod tests; diff --git a/crates/cp-win/src/virtual_files_test.rs b/crates/cp-win/src/virtual_files_test.rs new file mode 100644 index 00000000..81abeb4e --- /dev/null +++ b/crates/cp-win/src/virtual_files_test.rs @@ -0,0 +1,250 @@ +use super::*; +use cp_core::kind::Kind; + +fn inline(id: &str, bytes: &[u8]) -> Format { + Format { + id: id.into(), + payload: Payload::Inline(bytes.to_vec()), + } +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cp-virtual-{}-{name}", std::process::id())); + std::fs::remove_dir_all(&dir).ok(); + dir +} + +#[test] +fn a_descriptor_names_its_files_with_their_sizes_and_folders() { + let described = described_in(&descriptor_of(&[ + ("report.pdf", Some(1234), false), + ("attachments", None, true), + ("attachments\\photo copy.jpg", Some(5 << 32 | 7), false), + ])); + assert_eq!( + described, + vec![ + Described { + name: "report.pdf".into(), + size: Some(1234), + is_dir: false + }, + Described { + name: "attachments".into(), + size: None, + is_dir: true + }, + Described { + name: "attachments\\photo copy.jpg".into(), + size: Some(5 << 32 | 7), + is_dir: false + }, + ] + ); +} + +#[test] +fn the_name_runs_to_the_last_of_its_two_hundred_and_sixty_units() { + let longest = "n".repeat(NAME_UNITS - 1); + let described = described_in(&descriptor_of(&[(&longest, None, false)])); + assert_eq!(described[0].name, longest); + let accented = "ñ".repeat(200); + let described = described_in(&descriptor_of(&[(&accented, None, false)])); + assert_eq!(described[0].name, accented); +} + +#[test] +fn a_folder_needs_both_the_flag_and_the_attribute() { + let mut only_flag = descriptor_of(&[("x", None, true)]); + only_flag[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4].copy_from_slice(&0x20u32.to_le_bytes()); + assert!( + !described_in(&only_flag)[0].is_dir, + "FD_ATTRIBUTES on an ordinary file" + ); + let mut only_attribute = descriptor_of(&[("x", Some(1), false)]); + only_attribute[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] + .copy_from_slice(&FILE_ATTRIBUTE_DIRECTORY.to_le_bytes()); + assert!( + !described_in(&only_attribute)[0].is_dir, + "without FD_ATTRIBUTES the attribute counts for nothing" + ); + let mut both_bits = descriptor_of(&[("x", None, true)]); + both_bits[4 + ATTRIBUTES_AT..4 + ATTRIBUTES_AT + 4] + .copy_from_slice(&(FILE_ATTRIBUTE_DIRECTORY | 0x20).to_le_bytes()); + assert!(described_in(&both_bits)[0].is_dir); + let unsized_dir = described_in(&descriptor_of(&[("x", None, true)])); + assert_eq!(unsized_dir[0].size, None); +} + +#[test] +fn the_size_is_read_from_its_own_two_words() { + let mut swapped = descriptor_of(&[("x", Some(7), false)]); + swapped[4 + SIZE_HIGH_AT..4 + SIZE_HIGH_AT + 4].copy_from_slice(&3u32.to_le_bytes()); + swapped[4 + SIZE_LOW_AT..4 + SIZE_LOW_AT + 4].copy_from_slice(&0u32.to_le_bytes()); + assert_eq!(described_in(&swapped)[0].size, Some(3 << 32)); + assert_eq!( + described_in(&descriptor_of(&[("x", Some(u32::MAX as u64 + 2), false)]))[0].size, + Some(u32::MAX as u64 + 2) + ); +} + +#[test] +fn a_truncated_or_lying_descriptor_yields_what_it_really_holds() { + assert!(described_in(&[]).is_empty()); + assert!(described_in(&[0, 0, 0]).is_empty()); + let mut lying = descriptor_of(&[("a.txt", None, false)]); + lying[..4].copy_from_slice(&9u32.to_le_bytes()); + assert_eq!(described_in(&lying).len(), 1, "says nine, brings one"); + let cut = &descriptor_of(&[("a.txt", None, false)])[..300]; + assert!(described_in(cut).is_empty()); +} + +#[test] +fn the_contents_ids_count_from_the_descriptor() { + assert_eq!(contents_id(0), "FileContents#0"); + assert_eq!(index_of("FileContents#12"), Some(12)); + assert_eq!(index_of("FileContents"), None); + assert_eq!(index_of("FileContents#x"), None); + assert!(is_virtual(DESCRIPTOR)); + assert!(is_virtual(CONTENTS)); + assert!(is_virtual("FileContents#3")); + assert!(!is_virtual("CF_HDROP")); +} + +#[test] +fn virtual_files_are_offered_only_when_there_is_no_real_drop() { + assert!(offered_without_a_drop(&[ + DESCRIPTOR, + CONTENTS, + "RenPrivateItem" + ])); + assert!( + !offered_without_a_drop(&[DESCRIPTOR, CONTENTS, "CF_HDROP"]), + "Explorer offers both: the ones on disk win" + ); + assert!(!offered_without_a_drop(&[DESCRIPTOR])); + assert!(!offered_without_a_drop(&[CONTENTS])); +} + +#[test] +fn a_name_from_the_descriptor_never_escapes_its_folder() { + assert_eq!( + relative_path_of("report.pdf"), + Some(PathBuf::from("report.pdf")) + ); + assert_eq!( + relative_path_of("folder\\sub/note.txt"), + Some(PathBuf::from("folder").join("sub").join("note.txt")) + ); + for bad in [ + "", + "..\\x.txt", + "a\\..\\b", + ".", + "C:\\x.txt", + "\\\\srv\\share", + "bad.txt", + "trailing. ", + "tab\tname", + ] { + assert_eq!(relative_path_of(bad), None, "{bad:?}"); + } +} + +#[test] +fn materializing_writes_each_file_under_the_item_and_hands_back_the_top_paths() { + let root = scratch("write"); + let item = Item { + kind: Some(Kind::File), + formats: vec![ + inline( + DESCRIPTOR, + &descriptor_of(&[ + ("report.pdf", Some(4), false), + ("attachments", None, true), + ("attachments\\note.txt", Some(3), false), + ]), + ), + inline(&contents_id(0), b"%PDF"), + inline(&contents_id(2), b"hello"), + ], + }; + let out = materialize(&item, &root).expect("it was written"); + assert!(out.complete); + assert_eq!( + out.paths.len(), + 2, + "report.pdf and attachments: {:?}", + out.paths + ); + assert_eq!(std::fs::read(&out.paths[0]).expect("pdf"), b"%PDF"); + assert!(out.paths[1].is_dir()); + assert_eq!( + std::fs::read(out.paths[1].join("note.txt")).expect("note"), + b"hello" + ); + assert!( + out.paths[0].starts_with(&root), + "{:?} outside {root:?}", + out.paths[0] + ); + let again = materialize(&item, &root).expect("paste twice"); + assert_eq!( + again.paths, out.paths, + "the same fingerprint, the same folder" + ); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn what_never_arrived_or_cannot_be_named_leaves_the_paste_incomplete() { + let root = scratch("incomplete"); + let item = Item { + kind: Some(Kind::File), + formats: vec![ + inline( + DESCRIPTOR, + &descriptor_of(&[ + ("big.iso", Some(1 << 40), false), + ("..\\outside.txt", Some(1), false), + ("ok.txt", Some(2), false), + ]), + ), + Format { + id: contents_id(0), + payload: Payload::TooBig { size: 1 << 40 }, + }, + inline(&contents_id(1), b"x"), + inline(&contents_id(2), b"ok"), + ], + }; + let out = materialize(&item, &root).expect("something was written"); + assert!(!out.complete); + assert_eq!(out.paths.len(), 1); + assert!(out.paths[0].ends_with("ok.txt")); + assert!( + !root.join("outside.txt").exists(), + "nothing escapes the folder" + ); + std::fs::remove_dir_all(&root).ok(); +} + +#[test] +fn an_item_without_virtual_files_has_nothing_to_materialize() { + let root = scratch("none"); + assert_eq!(materialize(&Item::plain("hello"), &root), None); + let empty = Item { + kind: None, + formats: vec![inline(DESCRIPTOR, &descriptor_of(&[]))], + }; + assert_eq!(materialize(&empty, &root), None); + let announced = Item { + kind: None, + formats: vec![Format { + id: DESCRIPTOR.into(), + payload: Payload::Announced { size: Some(596) }, + }], + }; + assert_eq!(materialize(&announced, &root), None); + assert!(!root.exists(), "with no files, no folder is created"); +} diff --git a/crates/cp-win/src/watch.rs b/crates/cp-win/src/watch.rs index 570181a6..a15999eb 100644 --- a/crates/cp-win/src/watch.rs +++ b/crates/cp-win/src/watch.rs @@ -3,57 +3,5 @@ pub fn sequence(raw: u32) -> Option { } #[cfg(test)] -mod tests { - use super::*; - use cp_core::watch::{Cadence, Seen, Watcher}; - - #[test] - fn a_zero_is_not_a_counter() { - assert_eq!(sequence(0), None); - } - - #[test] - fn any_other_value_is_one() { - assert_eq!(sequence(1), Some(1)); - assert_eq!(sequence(915), Some(915)); - assert_eq!(sequence(u32::MAX), Some(i64::from(u32::MAX))); - } - - #[test] - fn the_upper_half_of_the_range_does_not_turn_negative() { - for raw in [0x8000_0000u32, 0xFFFF_FFFF] { - let counted = sequence(raw).expect("no es cero"); - assert!(counted > 0, "{raw} came out as {counted}"); - } - } - - #[test] - fn locking_the_screen_without_copying_adds_nothing() { - let mut watcher = Watcher::new(Cadence::Opaque); - let mut fresh = 0; - for raw in [915u32, 0, 0, 915] { - let Some(counted) = sequence(raw) else { - continue; - }; - if let Seen::Fresh { .. } = watcher.tick(counted) { - fresh += 1; - } - } - assert_eq!(fresh, 0, "nobody copied, and nothing was recorded"); - } - - #[test] - fn a_copy_made_across_the_lock_is_still_seen() { - let mut watcher = Watcher::new(Cadence::Opaque); - let mut fresh = 0; - for raw in [915u32, 0, 920] { - let Some(counted) = sequence(raw) else { - continue; - }; - if let Seen::Fresh { .. } = watcher.tick(counted) { - fresh += 1; - } - } - assert_eq!(fresh, 1); - } -} +#[path = "watch_test.rs"] +mod tests; diff --git a/crates/cp-win/src/watch_test.rs b/crates/cp-win/src/watch_test.rs new file mode 100644 index 00000000..a3031117 --- /dev/null +++ b/crates/cp-win/src/watch_test.rs @@ -0,0 +1,52 @@ +use super::*; +use cp_core::watch::{Cadence, Seen, Watcher}; + +#[test] +fn a_zero_is_not_a_counter() { + assert_eq!(sequence(0), None); +} + +#[test] +fn any_other_value_is_one() { + assert_eq!(sequence(1), Some(1)); + assert_eq!(sequence(915), Some(915)); + assert_eq!(sequence(u32::MAX), Some(i64::from(u32::MAX))); +} + +#[test] +fn the_upper_half_of_the_range_does_not_turn_negative() { + for raw in [0x8000_0000u32, 0xFFFF_FFFF] { + let counted = sequence(raw).expect("no es cero"); + assert!(counted > 0, "{raw} came out as {counted}"); + } +} + +#[test] +fn locking_the_screen_without_copying_adds_nothing() { + let mut watcher = Watcher::new(Cadence::Opaque); + let mut fresh = 0; + for raw in [915u32, 0, 0, 915] { + let Some(counted) = sequence(raw) else { + continue; + }; + if let Seen::Fresh { .. } = watcher.tick(counted) { + fresh += 1; + } + } + assert_eq!(fresh, 0, "nobody copied, and nothing was recorded"); +} + +#[test] +fn a_copy_made_across_the_lock_is_still_seen() { + let mut watcher = Watcher::new(Cadence::Opaque); + let mut fresh = 0; + for raw in [915u32, 0, 920] { + let Some(counted) = sequence(raw) else { + continue; + }; + if let Seen::Fresh { .. } = watcher.tick(counted) { + fresh += 1; + } + } + assert_eq!(fresh, 1); +} diff --git a/crates/cp-win/src/watching.rs b/crates/cp-win/src/watching.rs index 4f84c7a9..cb790b9a 100644 --- a/crates/cp-win/src/watching.rs +++ b/crates/cp-win/src/watching.rs @@ -71,36 +71,5 @@ impl Drop for Watching { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_period_keeps_up_with_a_person_without_spinning() { - assert!( - EVERY <= Duration::from_millis(250), - "una copia no puede tardar en verse" - ); - assert!( - EVERY >= Duration::from_millis(16), - "ni sondear mas rapido que la pantalla" - ); - } - - #[test] - fn stopping_is_what_drop_does_and_it_waits_for_the_thread() { - let watching = Watching::every(Duration::from_millis(5), || {}); - assert!(watching.thread.is_some()); - drop(watching); - } - - #[test] - fn a_long_period_does_not_make_stopping_take_that_long() { - let watching = Watching::every(Duration::from_secs(3600), || {}); - let started = std::time::Instant::now(); - drop(watching); - assert!( - started.elapsed() < Duration::from_secs(1), - "cerrar la aplicacion no puede esperar al siguiente sondeo" - ); - } -} +#[path = "watching_test.rs"] +mod tests; diff --git a/crates/cp-win/src/watching_test.rs b/crates/cp-win/src/watching_test.rs new file mode 100644 index 00000000..d7180573 --- /dev/null +++ b/crates/cp-win/src/watching_test.rs @@ -0,0 +1,31 @@ +use super::*; + +#[test] +fn the_period_keeps_up_with_a_person_without_spinning() { + assert!( + EVERY <= Duration::from_millis(250), + "una copia no puede tardar en verse" + ); + assert!( + EVERY >= Duration::from_millis(16), + "ni sondear mas rapido que la pantalla" + ); +} + +#[test] +fn stopping_is_what_drop_does_and_it_waits_for_the_thread() { + let watching = Watching::every(Duration::from_millis(5), || {}); + assert!(watching.thread.is_some()); + drop(watching); +} + +#[test] +fn a_long_period_does_not_make_stopping_take_that_long() { + let watching = Watching::every(Duration::from_secs(3600), || {}); + let started = std::time::Instant::now(); + drop(watching); + assert!( + started.elapsed() < Duration::from_secs(1), + "cerrar la aplicacion no puede esperar al siguiente sondeo" + ); +} From 41a05c70a37438cfe951b077beb06fcce6415419 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 19:53:24 -0300 Subject: [PATCH 4/9] =?UTF-8?q?refactor(app):=20the=20tests=20leave=20the?= =?UTF-8?q?=20files=20they=20test=20=E2=80=94=20the=20panel=20and=20the=20?= =?UTF-8?q?tray=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 test modules out of 17 files, 1.484 lines, and the last of the 94. cp-panel 68 → 68 tests, cp-gui 26 → 26. With this, no `#[cfg(test)] mod` with a body is left anywhere in `crates` or `app/src-tauri/src`: 94 declarations, 94 siblings, 0 blocks. The whole tree still runs 853 tests, clippy is clean across the workspace with `-D warnings`, rustfmt is conforming, and the ceiling passes with the measured code unchanged. What this buys is that a file can be read: 14.726 lines of test are no longer sitting between the code and the reader, and the counter measures what is in front of it instead of what it deduced by skipping blocks. --- app/src-tauri/src/backup.rs | 18 +- app/src-tauri/src/backup_test.rs | 14 + app/src-tauri/src/keys.rs | 22 +- app/src-tauri/src/keys_test.rs | 18 + app/src-tauri/src/note.rs | 24 +- app/src-tauri/src/note_test.rs | 20 ++ app/src-tauri/src/panel.rs | 11 +- app/src-tauri/src/panel_test.rs | 7 + app/src-tauri/src/tray.rs | 52 +-- app/src-tauri/src/tray_test.rs | 46 +++ app/src-tauri/src/trust.rs | 27 +- app/src-tauri/src/trust_test.rs | 23 ++ app/src-tauri/src/update.rs | 159 +-------- app/src-tauri/src/update_test.rs | 155 +++++++++ app/src-tauri/src/waking.rs | 95 +----- app/src-tauri/src/waking_test_macos.rs | 53 +++ app/src-tauri/src/waking_test_windows.rs | 34 ++ crates/cp-panel/src/age.rs | 53 +-- crates/cp-panel/src/age_test.rs | 49 +++ crates/cp-panel/src/app.rs | 52 +-- crates/cp-panel/src/app_test.rs | 48 +++ crates/cp-panel/src/engine.rs | 304 +---------------- crates/cp-panel/src/engine_test.rs | 299 +++++++++++++++++ crates/cp-panel/src/here.rs | 33 +- crates/cp-panel/src/here_test.rs | 29 ++ crates/cp-panel/src/measure.rs | 14 +- crates/cp-panel/src/measure_test.rs | 10 + crates/cp-panel/src/model.rs | 222 +------------ crates/cp-panel/src/model_test.rs | 218 ++++++++++++ crates/cp-panel/src/note.rs | 43 +-- crates/cp-panel/src/note_test.rs | 39 +++ crates/cp-panel/src/say.rs | 29 +- crates/cp-panel/src/say_test.rs | 25 ++ crates/cp-panel/src/view.rs | 401 +---------------------- crates/cp-panel/src/view_test.rs | 397 ++++++++++++++++++++++ 35 files changed, 1520 insertions(+), 1523 deletions(-) create mode 100644 app/src-tauri/src/backup_test.rs create mode 100644 app/src-tauri/src/keys_test.rs create mode 100644 app/src-tauri/src/note_test.rs create mode 100644 app/src-tauri/src/panel_test.rs create mode 100644 app/src-tauri/src/tray_test.rs create mode 100644 app/src-tauri/src/trust_test.rs create mode 100644 app/src-tauri/src/update_test.rs create mode 100644 app/src-tauri/src/waking_test_macos.rs create mode 100644 app/src-tauri/src/waking_test_windows.rs create mode 100644 crates/cp-panel/src/age_test.rs create mode 100644 crates/cp-panel/src/app_test.rs create mode 100644 crates/cp-panel/src/engine_test.rs create mode 100644 crates/cp-panel/src/here_test.rs create mode 100644 crates/cp-panel/src/measure_test.rs create mode 100644 crates/cp-panel/src/model_test.rs create mode 100644 crates/cp-panel/src/note_test.rs create mode 100644 crates/cp-panel/src/say_test.rs create mode 100644 crates/cp-panel/src/view_test.rs diff --git a/app/src-tauri/src/backup.rs b/app/src-tauri/src/backup.rs index 99ed405a..50c02900 100644 --- a/app/src-tauri/src/backup.rs +++ b/app/src-tauri/src/backup.rs @@ -105,22 +105,8 @@ fn bring(from: &std::path::Path, at: i64) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_history_a_backup_speaks_for_is_the_one_the_panel_writes() { - let Ok(db) = history() else { - return; - }; - let dir = crate::settings::folder().expect("a folder"); - assert!(db.starts_with(&dir)); - assert_eq!( - db.file_name().and_then(|it| it.to_str()), - Some("history.db") - ); - } -} +#[path = "backup_test.rs"] +mod tests; #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] diff --git a/app/src-tauri/src/backup_test.rs b/app/src-tauri/src/backup_test.rs new file mode 100644 index 00000000..def1d140 --- /dev/null +++ b/app/src-tauri/src/backup_test.rs @@ -0,0 +1,14 @@ +use super::*; + +#[test] +fn the_history_a_backup_speaks_for_is_the_one_the_panel_writes() { + let Ok(db) = history() else { + return; + }; + let dir = crate::settings::folder().expect("a folder"); + assert!(db.starts_with(&dir)); + assert_eq!( + db.file_name().and_then(|it| it.to_str()), + Some("history.db") + ); +} diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs index 746ea950..2f57b44e 100644 --- a/app/src-tauri/src/keys.rs +++ b/app/src-tauri/src/keys.rs @@ -59,23 +59,5 @@ pub fn keys(app: tauri::AppHandle) -> Keys { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn what_the_settings_file_ships_with_is_a_combination_the_system_knows() { - assert!(cp_config::SHORTCUT.parse::().is_ok()); - } - - #[test] - fn a_combination_nobody_could_press_is_refused_before_the_system_sees_it() { - assert!("".parse::().is_err()); - assert!("Ctrl+".parse::().is_err()); - } - - #[test] - fn nothing_is_bound_until_the_system_says_yes() { - let bound = Bound::default(); - assert!(bound.0.lock().expect("unpoisoned").is_none()); - } -} +#[path = "keys_test.rs"] +mod tests; diff --git a/app/src-tauri/src/keys_test.rs b/app/src-tauri/src/keys_test.rs new file mode 100644 index 00000000..21668aae --- /dev/null +++ b/app/src-tauri/src/keys_test.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn what_the_settings_file_ships_with_is_a_combination_the_system_knows() { + assert!(cp_config::SHORTCUT.parse::().is_ok()); +} + +#[test] +fn a_combination_nobody_could_press_is_refused_before_the_system_sees_it() { + assert!("".parse::().is_err()); + assert!("Ctrl+".parse::().is_err()); +} + +#[test] +fn nothing_is_bound_until_the_system_says_yes() { + let bound = Bound::default(); + assert!(bound.0.lock().expect("unpoisoned").is_none()); +} diff --git a/app/src-tauri/src/note.rs b/app/src-tauri/src/note.rs index a6c34026..90a0fb35 100644 --- a/app/src-tauri/src/note.rs +++ b/app/src-tauri/src/note.rs @@ -39,25 +39,5 @@ fn clock() -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_log_sits_where_the_privacy_note_says_it_does() { - let path = where_to(); - assert_eq!( - path.file_name().and_then(|it| it.to_str()), - Some("cp-gui.log") - ); - if let Some(dir) = crate::settings::folder() { - assert!(path.starts_with(dir)); - } - } - - #[test] - fn the_clock_reads_as_hours_minutes_and_seconds() { - let said = clock(); - assert_eq!(said.len(), 8); - assert!(said.chars().filter(|one| *one == ':').count() == 2); - } -} +#[path = "note_test.rs"] +mod tests; diff --git a/app/src-tauri/src/note_test.rs b/app/src-tauri/src/note_test.rs new file mode 100644 index 00000000..c1e8b373 --- /dev/null +++ b/app/src-tauri/src/note_test.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn the_log_sits_where_the_privacy_note_says_it_does() { + let path = where_to(); + assert_eq!( + path.file_name().and_then(|it| it.to_str()), + Some("cp-gui.log") + ); + if let Some(dir) = crate::settings::folder() { + assert!(path.starts_with(dir)); + } +} + +#[test] +fn the_clock_reads_as_hours_minutes_and_seconds() { + let said = clock(); + assert_eq!(said.len(), 8); + assert!(said.chars().filter(|one| *one == ':').count() == 2); +} diff --git a/app/src-tauri/src/panel.rs b/app/src-tauri/src/panel.rs index bcfe05bb..68a38f8b 100644 --- a/app/src-tauri/src/panel.rs +++ b/app/src-tauri/src/panel.rs @@ -179,12 +179,5 @@ pub enum Said { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_sidecar_starts_with_nobody_on_the_other_end() { - let sidecar = Sidecar::default(); - assert!(sidecar.0.lock().expect("unpoisoned").is_none()); - } -} +#[path = "panel_test.rs"] +mod tests; diff --git a/app/src-tauri/src/panel_test.rs b/app/src-tauri/src/panel_test.rs new file mode 100644 index 00000000..26071600 --- /dev/null +++ b/app/src-tauri/src/panel_test.rs @@ -0,0 +1,7 @@ +use super::*; + +#[test] +fn a_sidecar_starts_with_nobody_on_the_other_end() { + let sidecar = Sidecar::default(); + assert!(sidecar.0.lock().expect("unpoisoned").is_none()); +} diff --git a/app/src-tauri/src/tray.rs b/app/src-tauri/src/tray.rs index 2129dc77..1a3dd983 100644 --- a/app/src-tauri/src/tray.rs +++ b/app/src-tauri/src/tray.rs @@ -110,53 +110,5 @@ pub fn surface(app: &AppHandle) { } #[cfg(test)] -mod tests { - use super::spanish; - use tauri::image::Image; - - const WINDOWS: &[u8] = include_bytes!("../icons/tray/windows-32.png"); - const MACOS: &[u8] = include_bytes!("../icons/tray/macos@2x.png"); - - fn ink(png: &[u8]) -> (u32, usize) { - let art = Image::from_bytes(png).expect("the icon is a png"); - let pixels = art.rgba(); - let (sum, seen) = - pixels - .as_chunks::<4>() - .0 - .iter() - .fold((0u64, 0usize), |(sum, seen), px| { - if px[3] < 128 { - return (sum, seen); - } - let grey = - (px[0] as u64 * 299 + px[1] as u64 * 587 + px[2] as u64 * 114) / 1000; - (sum + grey, seen + 1) - }); - assert!(seen > 0, "the icon is transparent through and through"); - ((sum / seen as u64) as u32, seen) - } - - #[test] - fn the_bar_icon_on_macos_is_pale_because_the_system_paints_it_itself() { - let (grey, _) = ink(MACOS); - assert!(grey > 200, "the macOS template has to be pale: {grey}"); - } - - #[test] - fn only_a_locale_that_starts_with_en_gets_english() { - assert!(!spanish(Some("en"))); - assert!(!spanish(Some("en-GB"))); - assert!(!spanish(Some("EN-us"))); - assert!(spanish(Some("es"))); - assert!(spanish(Some("es-CL"))); - assert!(spanish(Some("pt-BR"))); - } - - #[test] - fn the_windows_icon_is_dark_enough_for_a_light_bar() { - let (grey, seen) = ink(WINDOWS); - assert!(seen > 64, "the Windows icon has hardly any ink: {seen}"); - assert!(grey < 200, "on a light bar it would not show: {grey}"); - } -} +#[path = "tray_test.rs"] +mod tests; diff --git a/app/src-tauri/src/tray_test.rs b/app/src-tauri/src/tray_test.rs new file mode 100644 index 00000000..00a0da11 --- /dev/null +++ b/app/src-tauri/src/tray_test.rs @@ -0,0 +1,46 @@ +use super::spanish; +use tauri::image::Image; + +const WINDOWS: &[u8] = include_bytes!("../icons/tray/windows-32.png"); +const MACOS: &[u8] = include_bytes!("../icons/tray/macos@2x.png"); + +fn ink(png: &[u8]) -> (u32, usize) { + let art = Image::from_bytes(png).expect("the icon is a png"); + let pixels = art.rgba(); + let (sum, seen) = pixels + .as_chunks::<4>() + .0 + .iter() + .fold((0u64, 0usize), |(sum, seen), px| { + if px[3] < 128 { + return (sum, seen); + } + let grey = (px[0] as u64 * 299 + px[1] as u64 * 587 + px[2] as u64 * 114) / 1000; + (sum + grey, seen + 1) + }); + assert!(seen > 0, "the icon is transparent through and through"); + ((sum / seen as u64) as u32, seen) +} + +#[test] +fn the_bar_icon_on_macos_is_pale_because_the_system_paints_it_itself() { + let (grey, _) = ink(MACOS); + assert!(grey > 200, "the macOS template has to be pale: {grey}"); +} + +#[test] +fn only_a_locale_that_starts_with_en_gets_english() { + assert!(!spanish(Some("en"))); + assert!(!spanish(Some("en-GB"))); + assert!(!spanish(Some("EN-us"))); + assert!(spanish(Some("es"))); + assert!(spanish(Some("es-CL"))); + assert!(spanish(Some("pt-BR"))); +} + +#[test] +fn the_windows_icon_is_dark_enough_for_a_light_bar() { + let (grey, seen) = ink(WINDOWS); + assert!(seen > 64, "the Windows icon has hardly any ink: {seen}"); + assert!(grey < 200, "on a light bar it would not show: {grey}"); +} diff --git a/app/src-tauri/src/trust.rs b/app/src-tauri/src/trust.rs index 596f578b..6cf87686 100644 --- a/app/src-tauri/src/trust.rs +++ b/app/src-tauri/src/trust.rs @@ -57,28 +57,5 @@ mod there { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn where_nothing_is_asked_nothing_is_offered() { - let said = trust(); - if !said.offered { - assert!(!said.pastes && !said.secure_input); - } - } - - #[cfg(target_os = "macos")] - #[test] - fn on_a_mac_the_question_always_has_an_answer() { - assert!(trust().offered); - } - - #[cfg(target_os = "macos")] - #[test] - fn pasting_is_never_claimed_without_one_of_the_two_permissions() { - let said = trust(); - let ready = cp_mac_sys::permissions::Readiness::probe(); - assert_eq!(said.pastes, ready.can_post || ready.accessibility); - } -} +#[path = "trust_test.rs"] +mod tests; diff --git a/app/src-tauri/src/trust_test.rs b/app/src-tauri/src/trust_test.rs new file mode 100644 index 00000000..bd900e9a --- /dev/null +++ b/app/src-tauri/src/trust_test.rs @@ -0,0 +1,23 @@ +use super::*; + +#[test] +fn where_nothing_is_asked_nothing_is_offered() { + let said = trust(); + if !said.offered { + assert!(!said.pastes && !said.secure_input); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn on_a_mac_the_question_always_has_an_answer() { + assert!(trust().offered); +} + +#[cfg(target_os = "macos")] +#[test] +fn pasting_is_never_claimed_without_one_of_the_two_permissions() { + let said = trust(); + let ready = cp_mac_sys::permissions::Readiness::probe(); + assert_eq!(said.pastes, ready.can_post || ready.accessibility); +} diff --git a/app/src-tauri/src/update.rs b/app/src-tauri/src/update.rs index 4c4cfcd6..c483d255 100644 --- a/app/src-tauri/src/update.rs +++ b/app/src-tauri/src/update.rs @@ -293,160 +293,5 @@ fn forget() { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_copy_the_store_keeps_is_updated_by_the_store() { - assert_eq!( - chosen( - Some(Path::new( - r"C:\Program Files\WindowsApps\CopyPaste\cp-gui.exe" - )), - UNBREWED - ), - Route::Store - ); - assert!(!self_installs(Route::Store)); - } - - const BREWED: fn(&Path) -> bool = |at| at == Path::new("/opt/homebrew/Caskroom/copypaste"); - const UNBREWED: fn(&Path) -> bool = |_| false; - - #[test] - fn the_cask_moves_the_bundle_out_of_the_caskroom_and_the_receipt_still_tells() { - let at = Path::new("/Applications/CopyPaste.app/Contents/MacOS/CopyPaste"); - if cfg!(target_os = "macos") { - assert_eq!( - chosen(Some(at), BREWED), - Route::Brew, - "the running path says Applications, the receipt says brew" - ); - } - assert_eq!( - chosen(Some(at), UNBREWED), - Route::Download, - "no receipt, no brew" - ); - assert!(!self_installs(Route::Brew)); - } - - #[test] - fn a_copy_running_from_the_caskroom_itself_is_brew_too() { - assert_eq!( - chosen( - Some(Path::new( - "/opt/homebrew/Caskroom/copypaste/3.0.0/CopyPaste.app/Contents/MacOS/CopyPaste" - )), - UNBREWED - ), - Route::Brew - ); - } - - #[test] - fn a_copy_somewhere_else_is_not_brew_however_many_casks_are_installed() { - assert_eq!( - chosen( - Some(Path::new( - "/Users/quien/Downloads/CopyPaste.app/Contents/MacOS/CopyPaste" - )), - BREWED - ), - Route::Download, - "another copy being brewed says nothing about the one running" - ); - assert_eq!(chosen(None, BREWED), Route::Download); - assert!(self_installs(Route::Download)); - } - - #[test] - fn a_copy_running_from_somewhere_read_only_knows_it_cannot_replace_itself() { - if cfg!(target_os = "macos") { - assert!(mounted(Some(Path::new("/Volumes/CopyPaste/CopyPaste.app")))); - assert!( - mounted(Some(Path::new( - "/private/var/folders/xy/AppTranslocation/1E2/d/CopyPaste.app" - ))), - "Gatekeeper's copy is read only too" - ); - assert!(!mounted(Some(Path::new("/Applications/CopyPaste.app")))); - } - assert!(!mounted(None)); - } - - #[test] - fn a_download_that_does_not_come_from_our_releases_is_refused() { - assert!(ours( - "https://github.com/rgdevment/CopyPaste/releases/download/v3.0.1/copypaste.exe" - )); - assert!(ours("https://objects.githubusercontent.com/whatever")); - assert!(!ours("https://evil.example.com/copypaste.exe")); - assert!(!ours("http://github.com/rgdevment/CopyPaste")); - assert!(!ours("https://github.com.evil.example.com/x")); - assert!(!ours("github.com/rgdevment")); - assert!(!ours("")); - } - - #[test] - fn a_clock_put_back_does_not_stop_the_next_look() { - let now = 1_000_000; - assert!(due(None, now)); - assert!(due(Some(now - APART), now)); - assert!(!due(Some(now - APART + 1), now)); - assert!(due(Some(now + 5_000), now), "a look in the future is stale"); - } - - #[test] - fn only_a_higher_version_is_worth_offering() { - assert!(worth_offering("3.0.1", "3.0.0")); - assert!(!worth_offering("3.0.0", "3.0.0")); - assert!(!worth_offering("2.9.9", "3.0.0")); - assert!(!worth_offering("not a version", "3.0.0")); - assert!(!worth_offering("3.0.1", "neither is this")); - } - - #[test] - fn a_candidate_is_not_offered_over_the_stable_it_came_from() { - assert!(!worth_offering("3.0.0-rc.1", "3.0.0")); - assert!(worth_offering("3.0.0", "3.0.0-rc.1")); - } - - #[test] - fn only_one_install_can_be_under_way_at_a_time() { - let alone = Installing::default(); - let first = alone.claim(); - assert!(first.is_some()); - assert!(alone.claim().is_none(), "a second one is turned away"); - drop(first); - assert!(alone.claim().is_some(), "and the next one may go"); - } - - #[test] - fn what_is_kept_survives_being_written_and_read_again() { - let one = Kept { - checked_at: Some(1_700_000_000), - found: Some("3.0.1".to_owned()), - }; - let said = serde_json::to_string(&one).expect("written"); - let back: Kept = serde_json::from_str(&said).expect("read"); - assert_eq!(back.checked_at, one.checked_at); - assert_eq!(back.found, one.found); - - let empty: Kept = serde_json::from_str("{}").expect("an empty one still reads"); - assert!(empty.checked_at.is_none() && empty.found.is_none()); - } - - #[test] - fn a_store_copy_never_claims_to_have_looked() { - let looked = Looked { - route: Route::Store, - looked: false, - ready: None, - }; - assert!( - !looked.looked, - "saying nothing is not saying it is up to date" - ); - } -} +#[path = "update_test.rs"] +mod tests; diff --git a/app/src-tauri/src/update_test.rs b/app/src-tauri/src/update_test.rs new file mode 100644 index 00000000..0e74a4d2 --- /dev/null +++ b/app/src-tauri/src/update_test.rs @@ -0,0 +1,155 @@ +use super::*; + +#[test] +fn a_copy_the_store_keeps_is_updated_by_the_store() { + assert_eq!( + chosen( + Some(Path::new( + r"C:\Program Files\WindowsApps\CopyPaste\cp-gui.exe" + )), + UNBREWED + ), + Route::Store + ); + assert!(!self_installs(Route::Store)); +} + +const BREWED: fn(&Path) -> bool = |at| at == Path::new("/opt/homebrew/Caskroom/copypaste"); +const UNBREWED: fn(&Path) -> bool = |_| false; + +#[test] +fn the_cask_moves_the_bundle_out_of_the_caskroom_and_the_receipt_still_tells() { + let at = Path::new("/Applications/CopyPaste.app/Contents/MacOS/CopyPaste"); + if cfg!(target_os = "macos") { + assert_eq!( + chosen(Some(at), BREWED), + Route::Brew, + "the running path says Applications, the receipt says brew" + ); + } + assert_eq!( + chosen(Some(at), UNBREWED), + Route::Download, + "no receipt, no brew" + ); + assert!(!self_installs(Route::Brew)); +} + +#[test] +fn a_copy_running_from_the_caskroom_itself_is_brew_too() { + assert_eq!( + chosen( + Some(Path::new( + "/opt/homebrew/Caskroom/copypaste/3.0.0/CopyPaste.app/Contents/MacOS/CopyPaste" + )), + UNBREWED + ), + Route::Brew + ); +} + +#[test] +fn a_copy_somewhere_else_is_not_brew_however_many_casks_are_installed() { + assert_eq!( + chosen( + Some(Path::new( + "/Users/quien/Downloads/CopyPaste.app/Contents/MacOS/CopyPaste" + )), + BREWED + ), + Route::Download, + "another copy being brewed says nothing about the one running" + ); + assert_eq!(chosen(None, BREWED), Route::Download); + assert!(self_installs(Route::Download)); +} + +#[test] +fn a_copy_running_from_somewhere_read_only_knows_it_cannot_replace_itself() { + if cfg!(target_os = "macos") { + assert!(mounted(Some(Path::new("/Volumes/CopyPaste/CopyPaste.app")))); + assert!( + mounted(Some(Path::new( + "/private/var/folders/xy/AppTranslocation/1E2/d/CopyPaste.app" + ))), + "Gatekeeper's copy is read only too" + ); + assert!(!mounted(Some(Path::new("/Applications/CopyPaste.app")))); + } + assert!(!mounted(None)); +} + +#[test] +fn a_download_that_does_not_come_from_our_releases_is_refused() { + assert!(ours( + "https://github.com/rgdevment/CopyPaste/releases/download/v3.0.1/copypaste.exe" + )); + assert!(ours("https://objects.githubusercontent.com/whatever")); + assert!(!ours("https://evil.example.com/copypaste.exe")); + assert!(!ours("http://github.com/rgdevment/CopyPaste")); + assert!(!ours("https://github.com.evil.example.com/x")); + assert!(!ours("github.com/rgdevment")); + assert!(!ours("")); +} + +#[test] +fn a_clock_put_back_does_not_stop_the_next_look() { + let now = 1_000_000; + assert!(due(None, now)); + assert!(due(Some(now - APART), now)); + assert!(!due(Some(now - APART + 1), now)); + assert!(due(Some(now + 5_000), now), "a look in the future is stale"); +} + +#[test] +fn only_a_higher_version_is_worth_offering() { + assert!(worth_offering("3.0.1", "3.0.0")); + assert!(!worth_offering("3.0.0", "3.0.0")); + assert!(!worth_offering("2.9.9", "3.0.0")); + assert!(!worth_offering("not a version", "3.0.0")); + assert!(!worth_offering("3.0.1", "neither is this")); +} + +#[test] +fn a_candidate_is_not_offered_over_the_stable_it_came_from() { + assert!(!worth_offering("3.0.0-rc.1", "3.0.0")); + assert!(worth_offering("3.0.0", "3.0.0-rc.1")); +} + +#[test] +fn only_one_install_can_be_under_way_at_a_time() { + let alone = Installing::default(); + let first = alone.claim(); + assert!(first.is_some()); + assert!(alone.claim().is_none(), "a second one is turned away"); + drop(first); + assert!(alone.claim().is_some(), "and the next one may go"); +} + +#[test] +fn what_is_kept_survives_being_written_and_read_again() { + let one = Kept { + checked_at: Some(1_700_000_000), + found: Some("3.0.1".to_owned()), + }; + let said = serde_json::to_string(&one).expect("written"); + let back: Kept = serde_json::from_str(&said).expect("read"); + assert_eq!(back.checked_at, one.checked_at); + assert_eq!(back.found, one.found); + + let empty: Kept = serde_json::from_str("{}").expect("an empty one still reads"); + assert!(empty.checked_at.is_none() && empty.found.is_none()); +} + +#[test] +fn a_store_copy_never_claims_to_have_looked() { + let looked = Looked { + route: Route::Store, + looked: false, + ready: None, + }; + assert!( + !looked.looked, + "saying nothing is not saying it is up to date" + ); +} diff --git a/app/src-tauri/src/waking.rs b/app/src-tauri/src/waking.rs index d6ea3a96..33dd5861 100644 --- a/app/src-tauri/src/waking.rs +++ b/app/src-tauri/src/waking.rs @@ -221,96 +221,9 @@ mod there { } #[cfg(all(test, windows))] -mod tests { - use super::there::{approves, ours}; - use std::path::Path; - - #[test] - fn only_the_two_bytes_that_mean_the_person_turned_it_off_count_as_off() { - assert!(!approves(&[3, 0, 0, 0])); - assert!(!approves(&[6, 0, 0, 0])); - assert!(approves(&[2, 0, 0, 0])); - assert!(approves(&[0, 0, 0, 0])); - assert!(approves(&[1, 0, 0, 0])); - assert!(approves(&[])); - } - - #[test] - fn an_entry_that_names_another_program_is_not_ours() { - let exe = Path::new(r"C:\Programas\CopyPaste\cp-gui.exe"); - assert!(ours(r"C:\Programas\CopyPaste\cp-gui.exe", exe)); - assert!(ours(r#""C:\Programas\CopyPaste\cp-gui.exe""#, exe)); - assert!(ours(r#" "c:\programas\copypaste\CP-GUI.EXE" "#, exe)); - assert!(!ours(r"C:\Otro\cp-gui.exe", exe)); - assert!(!ours(r#""""#, exe)); - assert!(!ours("", exe)); - } - - #[test] - fn an_entry_with_arguments_is_not_taken_for_the_bare_path() { - let exe = Path::new(r"C:\Programas\CopyPaste\cp-gui.exe"); - assert!(!ours(r"C:\Programas\CopyPaste\cp-gui.exe --hushed", exe)); - assert!(!ours( - r#""C:\Programas\CopyPaste\cp-gui.exe" --hushed"#, - exe - )); - assert!(!ours(r#""C:\Programas\CopyPaste\cp-gui.exe"#, exe)); - } -} +#[path = "waking_test_windows.rs"] +mod tests; #[cfg(all(test, target_os = "macos"))] -mod tests { - use super::there::{agent_for, ours, program_in}; - use std::path::Path; - - #[test] - fn the_agent_names_the_binary_that_wrote_it() { - let exe = Path::new("/Applications/CopyPaste.app/Contents/MacOS/CopyPaste"); - let written = agent_for(exe); - assert_eq!(program_in(&written).as_deref(), exe.to_str()); - assert!(written.contains("RunAtLoad")); - assert!(written.starts_with(""), None); - assert_eq!(program_in(""), None); - } -} +#[path = "waking_test_macos.rs"] +mod tests; diff --git a/app/src-tauri/src/waking_test_macos.rs b/app/src-tauri/src/waking_test_macos.rs new file mode 100644 index 00000000..a5582e9d --- /dev/null +++ b/app/src-tauri/src/waking_test_macos.rs @@ -0,0 +1,53 @@ +use super::there::{agent_for, ours, program_in}; +use std::path::Path; + +#[test] +fn the_agent_names_the_binary_that_wrote_it() { + let exe = Path::new("/Applications/CopyPaste.app/Contents/MacOS/CopyPaste"); + let written = agent_for(exe); + assert_eq!(program_in(&written).as_deref(), exe.to_str()); + assert!(written.contains("RunAtLoad")); + assert!(written.starts_with(""), None); + assert_eq!(program_in(""), None); +} diff --git a/app/src-tauri/src/waking_test_windows.rs b/app/src-tauri/src/waking_test_windows.rs new file mode 100644 index 00000000..f41c649c --- /dev/null +++ b/app/src-tauri/src/waking_test_windows.rs @@ -0,0 +1,34 @@ +use super::there::{approves, ours}; +use std::path::Path; + +#[test] +fn only_the_two_bytes_that_mean_the_person_turned_it_off_count_as_off() { + assert!(!approves(&[3, 0, 0, 0])); + assert!(!approves(&[6, 0, 0, 0])); + assert!(approves(&[2, 0, 0, 0])); + assert!(approves(&[0, 0, 0, 0])); + assert!(approves(&[1, 0, 0, 0])); + assert!(approves(&[])); +} + +#[test] +fn an_entry_that_names_another_program_is_not_ours() { + let exe = Path::new(r"C:\Programas\CopyPaste\cp-gui.exe"); + assert!(ours(r"C:\Programas\CopyPaste\cp-gui.exe", exe)); + assert!(ours(r#""C:\Programas\CopyPaste\cp-gui.exe""#, exe)); + assert!(ours(r#" "c:\programas\copypaste\CP-GUI.EXE" "#, exe)); + assert!(!ours(r"C:\Otro\cp-gui.exe", exe)); + assert!(!ours(r#""""#, exe)); + assert!(!ours("", exe)); +} + +#[test] +fn an_entry_with_arguments_is_not_taken_for_the_bare_path() { + let exe = Path::new(r"C:\Programas\CopyPaste\cp-gui.exe"); + assert!(!ours(r"C:\Programas\CopyPaste\cp-gui.exe --hushed", exe)); + assert!(!ours( + r#""C:\Programas\CopyPaste\cp-gui.exe" --hushed"#, + exe + )); + assert!(!ours(r#""C:\Programas\CopyPaste\cp-gui.exe"#, exe)); +} diff --git a/crates/cp-panel/src/age.rs b/crates/cp-panel/src/age.rs index 4177f0ca..d35e0a4b 100644 --- a/crates/cp-panel/src/age.rs +++ b/crates/cp-panel/src/age.rs @@ -34,54 +34,5 @@ fn age_in(english: bool, now: i64, at: i64) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_age_reads_the_way_the_card_shows_it() { - let now = 10 * DAY; - assert_eq!(age_text(now, now - 5_000), "ahora"); - assert_eq!(age_text(now, now - 9 * MINUTE), "9 min"); - assert_eq!(age_text(now, now - 3 * HOUR), "3 h"); - assert_eq!(age_text(now, now - 30 * HOUR), "ayer"); - assert_eq!(age_text(now, now - 3 * DAY), "3 d"); - assert_eq!(age_text(now, now - 9 * DAY), "1 sem"); - assert_eq!(age_text(now, now - 100 * DAY), "3 mes"); - assert_eq!(age_text(now, now - 800 * DAY), "2 a"); - } - - #[test] - fn each_unit_starts_exactly_at_its_threshold() { - let now = 1_000 * DAY; - assert_eq!(age_text(now, now - MINUTE + 1), "ahora"); - assert_eq!(age_text(now, now - MINUTE), "1 min"); - assert_eq!(age_text(now, now - HOUR + 1), "59 min"); - assert_eq!(age_text(now, now - HOUR), "1 h"); - assert_eq!(age_text(now, now - DAY + 1), "23 h"); - assert_eq!(age_text(now, now - DAY), "ayer"); - assert_eq!(age_text(now, now - 2 * DAY + 1), "ayer"); - assert_eq!(age_text(now, now - 2 * DAY), "2 d"); - assert_eq!(age_text(now, now - 7 * DAY + 1), "6 d"); - assert_eq!(age_text(now, now - 7 * DAY), "1 sem"); - assert_eq!(age_text(now, now - 30 * DAY + 1), "4 sem"); - assert_eq!(age_text(now, now - 30 * DAY), "1 mes"); - assert_eq!(age_text(now, now - 365 * DAY + 1), "12 mes"); - assert_eq!(age_text(now, now - 365 * DAY), "1 a"); - } - - #[test] - fn a_clock_that_runs_behind_the_item_is_still_now() { - assert_eq!(age_text(100, 5_000), "ahora"); - } - - #[test] - fn the_same_ages_read_in_english_when_english_is_the_tongue() { - let now = 10 * DAY; - assert_eq!(age_in(true, now, now - 5_000), "now"); - assert_eq!(age_in(true, now, now - 9 * MINUTE), "9 min"); - assert_eq!(age_in(true, now, now - 30 * HOUR), "yesterday"); - assert_eq!(age_in(true, now, now - 9 * DAY), "1 w"); - assert_eq!(age_in(true, now, now - 100 * DAY), "3 mo"); - assert_eq!(age_in(true, now, now - 800 * DAY), "2 y"); - } -} +#[path = "age_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/age_test.rs b/crates/cp-panel/src/age_test.rs new file mode 100644 index 00000000..ad795f42 --- /dev/null +++ b/crates/cp-panel/src/age_test.rs @@ -0,0 +1,49 @@ +use super::*; + +#[test] +fn the_age_reads_the_way_the_card_shows_it() { + let now = 10 * DAY; + assert_eq!(age_text(now, now - 5_000), "ahora"); + assert_eq!(age_text(now, now - 9 * MINUTE), "9 min"); + assert_eq!(age_text(now, now - 3 * HOUR), "3 h"); + assert_eq!(age_text(now, now - 30 * HOUR), "ayer"); + assert_eq!(age_text(now, now - 3 * DAY), "3 d"); + assert_eq!(age_text(now, now - 9 * DAY), "1 sem"); + assert_eq!(age_text(now, now - 100 * DAY), "3 mes"); + assert_eq!(age_text(now, now - 800 * DAY), "2 a"); +} + +#[test] +fn each_unit_starts_exactly_at_its_threshold() { + let now = 1_000 * DAY; + assert_eq!(age_text(now, now - MINUTE + 1), "ahora"); + assert_eq!(age_text(now, now - MINUTE), "1 min"); + assert_eq!(age_text(now, now - HOUR + 1), "59 min"); + assert_eq!(age_text(now, now - HOUR), "1 h"); + assert_eq!(age_text(now, now - DAY + 1), "23 h"); + assert_eq!(age_text(now, now - DAY), "ayer"); + assert_eq!(age_text(now, now - 2 * DAY + 1), "ayer"); + assert_eq!(age_text(now, now - 2 * DAY), "2 d"); + assert_eq!(age_text(now, now - 7 * DAY + 1), "6 d"); + assert_eq!(age_text(now, now - 7 * DAY), "1 sem"); + assert_eq!(age_text(now, now - 30 * DAY + 1), "4 sem"); + assert_eq!(age_text(now, now - 30 * DAY), "1 mes"); + assert_eq!(age_text(now, now - 365 * DAY + 1), "12 mes"); + assert_eq!(age_text(now, now - 365 * DAY), "1 a"); +} + +#[test] +fn a_clock_that_runs_behind_the_item_is_still_now() { + assert_eq!(age_text(100, 5_000), "ahora"); +} + +#[test] +fn the_same_ages_read_in_english_when_english_is_the_tongue() { + let now = 10 * DAY; + assert_eq!(age_in(true, now, now - 5_000), "now"); + assert_eq!(age_in(true, now, now - 9 * MINUTE), "9 min"); + assert_eq!(age_in(true, now, now - 30 * HOUR), "yesterday"); + assert_eq!(age_in(true, now, now - 9 * DAY), "1 w"); + assert_eq!(age_in(true, now, now - 100 * DAY), "3 mo"); + assert_eq!(age_in(true, now, now - 800 * DAY), "2 y"); +} diff --git a/crates/cp-panel/src/app.rs b/crates/cp-panel/src/app.rs index c8f022d2..381ff8a1 100644 --- a/crates/cp-panel/src/app.rs +++ b/crates/cp-panel/src/app.rs @@ -1206,53 +1206,5 @@ fn watch_signals(ui: slint::Weak, dir: std::path::PathBuf) { } #[cfg(test)] -mod tests { - #[test] - fn the_shortcut_sheet_is_written_in_both_tongues() { - let spanish = keys_sheet_in(false); - let english = keys_sheet_in(true); - assert_eq!(spanish.len(), english.len()); - assert!(!spanish.is_empty()); - for (es, en) in spanish.iter().zip(english.iter()) { - assert!(!es.label.is_empty() && !en.label.is_empty()); - assert!(!es.preview.is_empty() && !en.preview.is_empty()); - } - let shared = spanish - .iter() - .zip(english.iter()) - .filter(|(es, en)| es.label == en.label) - .count(); - assert_eq!(shared, 0, "not one row was left untranslated"); - } - - #[test] - fn the_filter_example_names_kinds_the_search_box_understands() { - for sheet in [keys_sheet_in(false), keys_sheet_in(true)] { - let row = sheet - .iter() - .find(|row| row.preview.starts_with('#')) - .expect("the filter row is there"); - for word in row.preview.split('·') { - let tag = word.trim().trim_start_matches('#'); - assert!( - crate::view::kind_from_word(tag).is_some(), - "«{tag}» is not a kind the search box knows" - ); - } - } - } - - use super::*; - - #[test] - fn choosing_a_theme_wins_over_what_the_system_wants() { - assert!(light_for(cp_config::Theme::Light, false)); - assert!(!light_for(cp_config::Theme::Dark, true)); - } - - #[test] - fn leaving_it_to_the_system_follows_the_system_both_ways() { - assert!(light_for(cp_config::Theme::System, true)); - assert!(!light_for(cp_config::Theme::System, false)); - } -} +#[path = "app_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/app_test.rs b/crates/cp-panel/src/app_test.rs new file mode 100644 index 00000000..5ac1ea76 --- /dev/null +++ b/crates/cp-panel/src/app_test.rs @@ -0,0 +1,48 @@ +#[test] +fn the_shortcut_sheet_is_written_in_both_tongues() { + let spanish = keys_sheet_in(false); + let english = keys_sheet_in(true); + assert_eq!(spanish.len(), english.len()); + assert!(!spanish.is_empty()); + for (es, en) in spanish.iter().zip(english.iter()) { + assert!(!es.label.is_empty() && !en.label.is_empty()); + assert!(!es.preview.is_empty() && !en.preview.is_empty()); + } + let shared = spanish + .iter() + .zip(english.iter()) + .filter(|(es, en)| es.label == en.label) + .count(); + assert_eq!(shared, 0, "not one row was left untranslated"); +} + +#[test] +fn the_filter_example_names_kinds_the_search_box_understands() { + for sheet in [keys_sheet_in(false), keys_sheet_in(true)] { + let row = sheet + .iter() + .find(|row| row.preview.starts_with('#')) + .expect("the filter row is there"); + for word in row.preview.split('·') { + let tag = word.trim().trim_start_matches('#'); + assert!( + crate::view::kind_from_word(tag).is_some(), + "«{tag}» is not a kind the search box knows" + ); + } + } +} + +use super::*; + +#[test] +fn choosing_a_theme_wins_over_what_the_system_wants() { + assert!(light_for(cp_config::Theme::Light, false)); + assert!(!light_for(cp_config::Theme::Dark, true)); +} + +#[test] +fn leaving_it_to_the_system_follows_the_system_both_ways() { + assert!(light_for(cp_config::Theme::System, true)); + assert!(!light_for(cp_config::Theme::System, false)); +} diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 4734915f..9edc57c9 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -298,305 +298,5 @@ fn jobs_for(item: &Item) -> &'static [&'static str] { } #[cfg(test)] -mod tests { - use super::*; - use cp_core::item::{Format, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; - - fn text(what: &str) -> Item { - Item { - kind: Some(Kind::Text), - formats: vec![Format { - id: SYNTHETIC_TEXT.into(), - payload: Payload::Inline(what.as_bytes().to_vec()), - }], - } - } - - fn image() -> Item { - Item { - kind: Some(Kind::Image), - formats: vec![Format { - id: SYNTHETIC_IMAGE.into(), - payload: Payload::Inline(vec![0x89, b'P', b'N', b'G']), - }], - } - } - - fn drawn(side: u32) -> Item { - let square = image::RgbaImage::from_pixel(side, side, image::Rgba([12, 200, 140, 255])); - let mut out = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(square) - .write_to(&mut out, image::ImageFormat::Png) - .expect("drawn"); - Item { - kind: Some(Kind::Image), - formats: vec![Format { - id: SYNTHETIC_IMAGE.into(), - payload: Payload::Inline(out.into_inner()), - }], - } - } - - #[cfg(target_os = "windows")] - fn files(paths: &[&str]) -> Item { - Item { - kind: Some(Kind::File), - formats: vec![Format { - id: "CF_HDROP".into(), - payload: Payload::Inline(cp_win::drop::drop_of(paths)), - }], - } - } - - #[cfg(target_os = "macos")] - fn files(paths: &[&str]) -> Item { - let urls = paths - .iter() - .map(|path| format!("file://{path}")) - .collect::>() - .join("\n"); - Item { - kind: Some(Kind::File), - formats: vec![Format { - id: "public.file-url".into(), - payload: Payload::Inline(urls.into_bytes()), - }], - } - } - - #[cfg(target_os = "windows")] - const SOMEWHERE: [&str; 2] = ["C:\\uno.txt", "C:\\dos.txt"]; - #[cfg(target_os = "macos")] - const SOMEWHERE: [&str; 2] = ["/tmp/uno.txt", "/tmp/dos.txt"]; - - #[test] - fn the_preview_of_text_is_the_text_itself() { - assert_eq!(preview_of(&text("hello world")), "hello world"); - } - - #[test] - fn an_image_has_no_preview_until_the_reading_gives_it_one() { - assert_eq!(preview_of(&image()), ""); - } - - #[test] - fn files_preview_as_their_paths_one_per_line() { - let said = preview_of(&files(&SOMEWHERE)); - assert_eq!(said, SOMEWHERE.join("\n")); - } - - #[test] - fn the_name_never_gives_away_what_was_copied() { - let secret = text("a password"); - let one = name_for(1_000, &secret); - let other = name_for(1_000, &secret); - assert_ne!(one, other, "two identical copies never share a name"); - let fingerprint = format!("{:016x}", secret.fingerprint()); - assert!( - !one.contains(&fingerprint), - "the name gives away what was copied" - ); - } - - #[test] - fn only_what_can_be_enriched_is_queued() { - assert_eq!(jobs_for(&image()), ["thumb", "ocr"]); - assert!(jobs_for(&text("nothing to do here")).is_empty()); - } - - #[test] - fn a_file_is_only_queued_where_its_thumbnail_can_be_drawn() { - let queued = jobs_for(&files(&SOMEWHERE)); - if here::THUMBNAILS_FILES { - assert_eq!(queued, ["thumb"]); - } else { - assert!( - queued.is_empty(), - "queueing what nobody will draw is no use" - ); - } - } - - fn somewhere() -> (tempfile::TempDir, Store) { - let dir = tempfile::tempdir().expect("a folder"); - let store = Store::open(&dir.path().join("history.db")).expect("opened"); - (dir, store) - } - - #[test] - fn without_a_window_in_front_the_card_simply_has_no_app() { - let (_dir, store) = somewhere(); - let id = keep(&store, &text("from nowhere at all"), 1_000, None).expect("stored"); - let page = store - .list(&cp_store::Filter::default(), 10, None) - .expect("listed"); - let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); - assert_eq!(mine.app, None); - } - - #[test] - fn what_is_copied_lands_with_its_preview_and_its_kind() { - let (_dir, store) = somewhere(); - let id = keep(&store, &text("the first thing"), 1_000, None).expect("stored"); - let kept = store.item(id).expect("read").expect("still there"); - assert_eq!(kept.kind, Some(Kind::Text)); - assert_eq!(store.count().expect("counted"), 1); - } - - #[test] - fn the_same_thing_copied_twice_is_one_row_that_rises() { - let (_dir, store) = somewhere(); - let first = keep(&store, &text("the same"), 1_000, None).expect("stored"); - let again = keep(&store, &text("the same"), 5_000, None).expect("recognised"); - assert_eq!(first, again); - assert_eq!(store.count().expect("counted"), 1); - } - - #[test] - fn two_different_copies_are_two_rows() { - let (_dir, store) = somewhere(); - keep(&store, &text("one"), 1_000, None).expect("stored"); - keep(&store, &text("another"), 2_000, None).expect("stored"); - assert_eq!(store.count().expect("counted"), 2); - } - - #[test] - fn an_image_leaves_its_reading_and_its_thumbnail_pending() { - let (_dir, store) = somewhere(); - let id = keep(&store, &image(), 1_000, None).expect("stored"); - let mut waiting = store.take_pending("ocr", 2_000, 10).expect("the queue"); - waiting.extend(store.take_pending("thumb", 2_000, 10).expect("the queue")); - assert_eq!(waiting, [id, id]); - } - - #[test] - fn what_was_copied_remembers_the_app_it_came_from() { - let (_dir, store) = somewhere(); - let id = keep(&store, &text("from the browser"), 1_000, Some("chrome")).expect("stored"); - let page = store - .list(&cp_store::Filter::default(), 10, None) - .expect("listed"); - let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); - assert_eq!(mine.app.as_deref(), Some("chrome")); - } - - #[test] - fn a_picture_ends_up_with_a_thumbnail_it_can_show() { - let (dir, store) = somewhere(); - let thumbs = dir.path().join("thumbs"); - let id = keep(&store, &drawn(600), 1_000, None).expect("stored"); - assert!(errand(&store, &thumbs), "there was work to do"); - let page = store - .list(&cp_store::Filter::default(), 10, None) - .expect("listed"); - let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); - let made = mine.thumb_path.as_deref().expect("it has a thumbnail"); - assert!( - std::path::Path::new(made).exists(), - "{made} was never written" - ); - let side = - cp_core::thumbnail::size_of(&std::fs::read(made).expect("read")).expect("a size"); - assert!(side.width <= cp_core::thumbnail::MAX_SIDE); - assert!( - store - .take_pending("thumb", 2_000, 10) - .expect("the queue") - .is_empty() - ); - } - - #[test] - fn what_the_settings_say_becomes_what_the_store_sweeps() { - let kept = cp_config::Config { - keeps_days: Some(30), - images_quota_mb: Some(512), - ..cp_config::Config::default() - }; - let policy = policy_of(&kept); - assert_eq!(policy.keep_for, Some(30 * A_DAY)); - assert_eq!(policy.bytes_at_most, Some(512 * 1024 * 1024)); - } - - #[test] - fn keeping_things_forever_and_without_a_limit_sweeps_nothing() { - let kept = cp_config::Config { - keeps_days: None, - images_quota_mb: None, - ..cp_config::Config::default() - }; - assert_eq!(policy_of(&kept), cp_store::Policy::default()); - let zeroed = cp_config::Config { - keeps_days: Some(0), - images_quota_mb: Some(0), - ..cp_config::Config::default() - }; - assert_eq!(policy_of(&zeroed), cp_store::Policy::default()); - } - - #[test] - fn what_is_older_than_the_setting_goes_and_what_is_pinned_stays() { - let (_dir, store) = somewhere(); - let now = 100 * A_DAY; - let old = keep(&store, &text("from long ago"), now - 40 * A_DAY, None).expect("stored"); - let recent = keep(&store, &text("from yesterday"), now - A_DAY, None).expect("stored"); - let pinned = keep(&store, &text("pinned and old"), now - 40 * A_DAY, None).expect("stored"); - store.set_pinned(pinned, true, now).expect("pinned"); - let kept = cp_config::Config { - keeps_days: Some(30), - ..cp_config::Config::default() - }; - store.sweep(&policy_of(&kept), now).expect("swept"); - let page = store - .list(&cp_store::Filter::default(), 10, None) - .expect("listed"); - let left: Vec = page.rows.iter().map(|one| one.id).collect(); - assert!(!left.contains(&old), "what was old had to go"); - assert!(left.contains(&recent), "what is recent stays"); - assert!(left.contains(&pinned), "what is pinned never expires"); - } - - #[test] - fn nothing_waiting_means_nothing_to_do() { - let (dir, store) = somewhere(); - assert!(!errand(&store, &dir.path().join("thumbs"))); - } - - #[test] - fn something_that_cannot_be_drawn_waits_instead_of_spinning() { - let (dir, store) = somewhere(); - let thumbs = dir.path().join("thumbs"); - let id = keep(&store, &image(), 1_000, None).expect("stored"); - assert!(errand(&store, &thumbs), "it gave it a try"); - let now = crate::app::now_ms(); - assert!( - store - .take_pending("thumb", now, 10) - .expect("the queue") - .is_empty(), - "it is not retried straight away" - ); - let later = store - .take_pending("thumb", now + LATER + 1_000, 10) - .expect("the queue"); - assert_eq!(later, [id], "its turn comes round again later"); - } - - #[test] - fn plain_text_asks_nobody_for_anything() { - let (_dir, store) = somewhere(); - keep(&store, &text("with no trimmings"), 1_000, None).expect("stored"); - assert!( - store - .take_pending("thumb", 2_000, 10) - .expect("the queue") - .is_empty() - ); - assert!( - store - .take_pending("ocr", 2_000, 10) - .expect("the queue") - .is_empty() - ); - } -} +#[path = "engine_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs new file mode 100644 index 00000000..7acb41fb --- /dev/null +++ b/crates/cp-panel/src/engine_test.rs @@ -0,0 +1,299 @@ +use super::*; +use cp_core::item::{Format, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; + +fn text(what: &str) -> Item { + Item { + kind: Some(Kind::Text), + formats: vec![Format { + id: SYNTHETIC_TEXT.into(), + payload: Payload::Inline(what.as_bytes().to_vec()), + }], + } +} + +fn image() -> Item { + Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(vec![0x89, b'P', b'N', b'G']), + }], + } +} + +fn drawn(side: u32) -> Item { + let square = image::RgbaImage::from_pixel(side, side, image::Rgba([12, 200, 140, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(square) + .write_to(&mut out, image::ImageFormat::Png) + .expect("drawn"); + Item { + kind: Some(Kind::Image), + formats: vec![Format { + id: SYNTHETIC_IMAGE.into(), + payload: Payload::Inline(out.into_inner()), + }], + } +} + +#[cfg(target_os = "windows")] +fn files(paths: &[&str]) -> Item { + Item { + kind: Some(Kind::File), + formats: vec![Format { + id: "CF_HDROP".into(), + payload: Payload::Inline(cp_win::drop::drop_of(paths)), + }], + } +} + +#[cfg(target_os = "macos")] +fn files(paths: &[&str]) -> Item { + let urls = paths + .iter() + .map(|path| format!("file://{path}")) + .collect::>() + .join("\n"); + Item { + kind: Some(Kind::File), + formats: vec![Format { + id: "public.file-url".into(), + payload: Payload::Inline(urls.into_bytes()), + }], + } +} + +#[cfg(target_os = "windows")] +const SOMEWHERE: [&str; 2] = ["C:\\uno.txt", "C:\\dos.txt"]; +#[cfg(target_os = "macos")] +const SOMEWHERE: [&str; 2] = ["/tmp/uno.txt", "/tmp/dos.txt"]; + +#[test] +fn the_preview_of_text_is_the_text_itself() { + assert_eq!(preview_of(&text("hello world")), "hello world"); +} + +#[test] +fn an_image_has_no_preview_until_the_reading_gives_it_one() { + assert_eq!(preview_of(&image()), ""); +} + +#[test] +fn files_preview_as_their_paths_one_per_line() { + let said = preview_of(&files(&SOMEWHERE)); + assert_eq!(said, SOMEWHERE.join("\n")); +} + +#[test] +fn the_name_never_gives_away_what_was_copied() { + let secret = text("a password"); + let one = name_for(1_000, &secret); + let other = name_for(1_000, &secret); + assert_ne!(one, other, "two identical copies never share a name"); + let fingerprint = format!("{:016x}", secret.fingerprint()); + assert!( + !one.contains(&fingerprint), + "the name gives away what was copied" + ); +} + +#[test] +fn only_what_can_be_enriched_is_queued() { + assert_eq!(jobs_for(&image()), ["thumb", "ocr"]); + assert!(jobs_for(&text("nothing to do here")).is_empty()); +} + +#[test] +fn a_file_is_only_queued_where_its_thumbnail_can_be_drawn() { + let queued = jobs_for(&files(&SOMEWHERE)); + if here::THUMBNAILS_FILES { + assert_eq!(queued, ["thumb"]); + } else { + assert!( + queued.is_empty(), + "queueing what nobody will draw is no use" + ); + } +} + +fn somewhere() -> (tempfile::TempDir, Store) { + let dir = tempfile::tempdir().expect("a folder"); + let store = Store::open(&dir.path().join("history.db")).expect("opened"); + (dir, store) +} + +#[test] +fn without_a_window_in_front_the_card_simply_has_no_app() { + let (_dir, store) = somewhere(); + let id = keep(&store, &text("from nowhere at all"), 1_000, None).expect("stored"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listed"); + let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); + assert_eq!(mine.app, None); +} + +#[test] +fn what_is_copied_lands_with_its_preview_and_its_kind() { + let (_dir, store) = somewhere(); + let id = keep(&store, &text("the first thing"), 1_000, None).expect("stored"); + let kept = store.item(id).expect("read").expect("still there"); + assert_eq!(kept.kind, Some(Kind::Text)); + assert_eq!(store.count().expect("counted"), 1); +} + +#[test] +fn the_same_thing_copied_twice_is_one_row_that_rises() { + let (_dir, store) = somewhere(); + let first = keep(&store, &text("the same"), 1_000, None).expect("stored"); + let again = keep(&store, &text("the same"), 5_000, None).expect("recognised"); + assert_eq!(first, again); + assert_eq!(store.count().expect("counted"), 1); +} + +#[test] +fn two_different_copies_are_two_rows() { + let (_dir, store) = somewhere(); + keep(&store, &text("one"), 1_000, None).expect("stored"); + keep(&store, &text("another"), 2_000, None).expect("stored"); + assert_eq!(store.count().expect("counted"), 2); +} + +#[test] +fn an_image_leaves_its_reading_and_its_thumbnail_pending() { + let (_dir, store) = somewhere(); + let id = keep(&store, &image(), 1_000, None).expect("stored"); + let mut waiting = store.take_pending("ocr", 2_000, 10).expect("the queue"); + waiting.extend(store.take_pending("thumb", 2_000, 10).expect("the queue")); + assert_eq!(waiting, [id, id]); +} + +#[test] +fn what_was_copied_remembers_the_app_it_came_from() { + let (_dir, store) = somewhere(); + let id = keep(&store, &text("from the browser"), 1_000, Some("chrome")).expect("stored"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listed"); + let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); + assert_eq!(mine.app.as_deref(), Some("chrome")); +} + +#[test] +fn a_picture_ends_up_with_a_thumbnail_it_can_show() { + let (dir, store) = somewhere(); + let thumbs = dir.path().join("thumbs"); + let id = keep(&store, &drawn(600), 1_000, None).expect("stored"); + assert!(errand(&store, &thumbs), "there was work to do"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listed"); + let mine = page.rows.iter().find(|one| one.id == id).expect("is here"); + let made = mine.thumb_path.as_deref().expect("it has a thumbnail"); + assert!( + std::path::Path::new(made).exists(), + "{made} was never written" + ); + let side = cp_core::thumbnail::size_of(&std::fs::read(made).expect("read")).expect("a size"); + assert!(side.width <= cp_core::thumbnail::MAX_SIDE); + assert!( + store + .take_pending("thumb", 2_000, 10) + .expect("the queue") + .is_empty() + ); +} + +#[test] +fn what_the_settings_say_becomes_what_the_store_sweeps() { + let kept = cp_config::Config { + keeps_days: Some(30), + images_quota_mb: Some(512), + ..cp_config::Config::default() + }; + let policy = policy_of(&kept); + assert_eq!(policy.keep_for, Some(30 * A_DAY)); + assert_eq!(policy.bytes_at_most, Some(512 * 1024 * 1024)); +} + +#[test] +fn keeping_things_forever_and_without_a_limit_sweeps_nothing() { + let kept = cp_config::Config { + keeps_days: None, + images_quota_mb: None, + ..cp_config::Config::default() + }; + assert_eq!(policy_of(&kept), cp_store::Policy::default()); + let zeroed = cp_config::Config { + keeps_days: Some(0), + images_quota_mb: Some(0), + ..cp_config::Config::default() + }; + assert_eq!(policy_of(&zeroed), cp_store::Policy::default()); +} + +#[test] +fn what_is_older_than_the_setting_goes_and_what_is_pinned_stays() { + let (_dir, store) = somewhere(); + let now = 100 * A_DAY; + let old = keep(&store, &text("from long ago"), now - 40 * A_DAY, None).expect("stored"); + let recent = keep(&store, &text("from yesterday"), now - A_DAY, None).expect("stored"); + let pinned = keep(&store, &text("pinned and old"), now - 40 * A_DAY, None).expect("stored"); + store.set_pinned(pinned, true, now).expect("pinned"); + let kept = cp_config::Config { + keeps_days: Some(30), + ..cp_config::Config::default() + }; + store.sweep(&policy_of(&kept), now).expect("swept"); + let page = store + .list(&cp_store::Filter::default(), 10, None) + .expect("listed"); + let left: Vec = page.rows.iter().map(|one| one.id).collect(); + assert!(!left.contains(&old), "what was old had to go"); + assert!(left.contains(&recent), "what is recent stays"); + assert!(left.contains(&pinned), "what is pinned never expires"); +} + +#[test] +fn nothing_waiting_means_nothing_to_do() { + let (dir, store) = somewhere(); + assert!(!errand(&store, &dir.path().join("thumbs"))); +} + +#[test] +fn something_that_cannot_be_drawn_waits_instead_of_spinning() { + let (dir, store) = somewhere(); + let thumbs = dir.path().join("thumbs"); + let id = keep(&store, &image(), 1_000, None).expect("stored"); + assert!(errand(&store, &thumbs), "it gave it a try"); + let now = crate::app::now_ms(); + assert!( + store + .take_pending("thumb", now, 10) + .expect("the queue") + .is_empty(), + "it is not retried straight away" + ); + let later = store + .take_pending("thumb", now + LATER + 1_000, 10) + .expect("the queue"); + assert_eq!(later, [id], "its turn comes round again later"); +} + +#[test] +fn plain_text_asks_nobody_for_anything() { + let (_dir, store) = somewhere(); + keep(&store, &text("with no trimmings"), 1_000, None).expect("stored"); + assert!( + store + .take_pending("thumb", 2_000, 10) + .expect("the queue") + .is_empty() + ); + assert!( + store + .take_pending("ocr", 2_000, 10) + .expect("the queue") + .is_empty() + ); +} diff --git a/crates/cp-panel/src/here.rs b/crates/cp-panel/src/here.rs index 7809c1fa..771e9654 100644 --- a/crates/cp-panel/src/here.rs +++ b/crates/cp-panel/src/here.rs @@ -207,34 +207,5 @@ mod platform { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn what_the_platform_promises_about_file_thumbnails_is_what_it_does() { - if !THUMBNAILS_FILES { - assert!( - thumb_of_file(Path::new("/"), 128).is_none(), - "si no dibuja miniaturas de archivos, no puede devolver una" - ); - } - } - - #[test] - fn the_folders_the_panel_writes_into_hang_from_the_same_root() { - let Some(root) = data_dir() else { - return; - }; - assert!(thumbs_dir().expect("miniaturas").starts_with(&root)); - } - - #[test] - fn asking_the_system_for_its_theme_answers_the_same_twice() { - assert_eq!(system_is_light(), system_is_light()); - } - - #[test] - fn nobody_in_front_reads_as_zero_and_pasting_there_reaches_nobody() { - assert_eq!(paste_into(0, || {}), Sent::Nobody); - } -} +#[path = "here_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/here_test.rs b/crates/cp-panel/src/here_test.rs new file mode 100644 index 00000000..79396418 --- /dev/null +++ b/crates/cp-panel/src/here_test.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn what_the_platform_promises_about_file_thumbnails_is_what_it_does() { + if !THUMBNAILS_FILES { + assert!( + thumb_of_file(Path::new("/"), 128).is_none(), + "si no dibuja miniaturas de archivos, no puede devolver una" + ); + } +} + +#[test] +fn the_folders_the_panel_writes_into_hang_from_the_same_root() { + let Some(root) = data_dir() else { + return; + }; + assert!(thumbs_dir().expect("miniaturas").starts_with(&root)); +} + +#[test] +fn asking_the_system_for_its_theme_answers_the_same_twice() { + assert_eq!(system_is_light(), system_is_light()); +} + +#[test] +fn nobody_in_front_reads_as_zero_and_pasting_there_reaches_nobody() { + assert_eq!(paste_into(0, || {}), Sent::Nobody); +} diff --git a/crates/cp-panel/src/measure.rs b/crates/cp-panel/src/measure.rs index 3dafe2e0..f35a91cb 100644 --- a/crates/cp-panel/src/measure.rs +++ b/crates/cp-panel/src/measure.rs @@ -146,15 +146,5 @@ fn percentile(values: &[f64], pct: f64) -> f64 { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_percentile_is_the_nearest_rank_and_survives_an_empty_sample() { - let sample = [5.0, 1.0, 3.0, 2.0, 4.0]; - assert_eq!(percentile(&sample, 50.0), 3.0); - assert_eq!(percentile(&sample, 95.0), 5.0); - assert_eq!(percentile(&sample, 0.0), 1.0); - assert_eq!(percentile(&[], 95.0), 0.0); - } -} +#[path = "measure_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/measure_test.rs b/crates/cp-panel/src/measure_test.rs new file mode 100644 index 00000000..402336a4 --- /dev/null +++ b/crates/cp-panel/src/measure_test.rs @@ -0,0 +1,10 @@ +use super::*; + +#[test] +fn the_percentile_is_the_nearest_rank_and_survives_an_empty_sample() { + let sample = [5.0, 1.0, 3.0, 2.0, 4.0]; + assert_eq!(percentile(&sample, 50.0), 3.0); + assert_eq!(percentile(&sample, 95.0), 5.0); + assert_eq!(percentile(&sample, 0.0), 1.0); + assert_eq!(percentile(&[], 95.0), 0.0); +} diff --git a/crates/cp-panel/src/model.rs b/crates/cp-panel/src/model.rs index 2eb5504a..e7d62fb8 100644 --- a/crates/cp-panel/src/model.rs +++ b/crates/cp-panel/src/model.rs @@ -278,223 +278,5 @@ impl Model for Rows { } #[cfg(test)] -mod tests { - use super::*; - - const SIZES: Metrics = Metrics { - tall: 146.0, - plain: 86.0, - found: 68.0, - frame: 50.0, - line: 18.0, - }; - - fn store_with(count: usize) -> Rc { - let store = Store::in_memory().expect("esquema"); - for at in 0..count { - store - .insert_text(&format!("u{at}"), &format!("elemento {at}"), at as i64) - .expect("insert"); - } - Rc::new(store) - } - - fn open(store: Rc, now: i64) -> Rc { - Rows::open(store, Filter::default(), now, SIZES) - } - - #[test] - fn the_first_page_comes_with_the_model_and_the_rest_waits_for_the_view() { - let rows = open(store_with(300), 1_000); - assert_eq!(rows.row_count(), PAGE); - assert_eq!(rows.loaded(), PAGE); - assert_eq!(rows.row_data(0).map(|card| card.id), Some(300)); - let more = rows.load_page(); - assert_eq!(more, PAGE); - assert_eq!(rows.row_count(), 2 * PAGE); - assert_eq!(rows.load_page(), 60); - assert_eq!(rows.load_page(), 0, "there are no more pages"); - assert_eq!(rows.row_count(), 300); - } - - #[test] - fn a_row_is_built_once_and_read_back_from_the_cache() { - let rows = open(store_with(3), 5_000); - let first = rows.row_data(0).expect("fila"); - assert_eq!(first.body.as_str(), "elemento 2"); - assert_eq!(first.age.as_str(), "ahora"); - assert!(rows.cards.borrow()[0].is_some()); - let again = rows.row_data(0).expect("from the cache"); - assert_eq!((again.id, again.body.as_str()), (first.id, "elemento 2")); - assert_eq!(rows.row_data(9).map(|card| card.id), None); - } - - #[test] - fn the_next_page_is_wanted_only_near_the_end_and_only_once() { - let rows = open(store_with(300), 0); - assert!(!rows.needs_more(0)); - assert!(!rows.needs_more(PAGE - AHEAD - 1)); - assert!(rows.needs_more(PAGE - AHEAD)); - assert!(rows.needs_more(PAGE - 1)); - rows.loading.set(true); - assert!(!rows.needs_more(PAGE - 1), "a page is already on its way"); - rows.loading.set(false); - while rows.load_page() > 0 {} - assert!(!rows.needs_more(299), "no queda nada que pedir"); - } - - #[test] - fn a_row_written_from_the_view_is_what_comes_back() { - let rows = open(store_with(2), 0); - let mut card = rows.row_data(1).expect("fila"); - card.body = "editado".into(); - rows.set_row_data(1, card); - assert_eq!( - rows.row_data(1).map(|c| c.body.to_string()), - Some("editado".into()) - ); - rows.set_row_data(9, Card::default()); - assert_eq!(rows.row_count(), 2, "fuera de rango no crea filas"); - } - - #[test] - fn a_query_that_matches_nothing_is_an_empty_model_not_an_error() { - let filter = Filter { - query: Some("nada-de-esto".into()), - ..Default::default() - }; - let rows = Rows::open(store_with(10), filter, 0, SIZES); - assert_eq!(rows.row_count(), 0); - assert!(rows.exhausted.get()); - } - - #[test] - fn every_row_knows_where_it_starts_and_a_thumbnail_makes_it_taller() { - let store = store_with(3); - store.set_thumb(2, Some("miniatura.png"), 1).expect("thumb"); - let rows = open(store, 0); - assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); - assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.tall))); - assert_eq!( - rows.span_of(2), - Some((SIZES.plain + SIZES.tall, SIZES.plain)) - ); - assert_eq!(rows.span_of(3), None, "no hay cuarta fila"); - } - - #[test] - fn a_thumbnail_that_does_not_load_gives_its_height_back_to_the_rows_below() { - let store = store_with(3); - store.set_thumb(3, Some("no-existe.png"), 2).expect("thumb"); - let rows = open(store, 0); - assert_eq!(rows.span_of(0), Some((0.0, SIZES.tall))); - let card = rows.row_data(0).expect("fila"); - assert!(!card.has_thumb, "the thumbnail is not on disk"); - assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); - assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); - } - - #[test] - fn the_open_row_grows_what_its_text_asks_and_nobody_else_pays() { - let store = Store::in_memory().expect("esquema"); - store.insert_text("u0", "corto", 0).expect("insert"); - store - .insert_text("u1", &"palabra ".repeat(40), 1) - .expect("insert"); - store.insert_text("u2", "otro corto", 2).expect("insert"); - let rows = open(Rc::new(store), 0); - let long = SIZES.frame + 6.0 * SIZES.line; - - assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); - - rows.open_at(Some(1)); - assert_eq!( - rows.span_of(0), - Some((0.0, SIZES.plain)), - "la de arriba no se mueve" - ); - assert_eq!( - rows.span_of(1), - Some((SIZES.plain, long)), - "crece lo que pide su texto" - ); - assert_eq!( - rows.span_of(2), - Some((SIZES.plain + long, SIZES.plain)), - "the one below drops by what the open one grew" - ); - - rows.open_at(Some(2)); - assert_eq!( - rows.span_of(1), - Some((SIZES.plain, SIZES.plain)), - "la anterior vuelve" - ); - assert_eq!( - rows.span_of(2), - Some((2.0 * SIZES.plain, SIZES.plain)), - "un texto corto no gana nada al abrirse" - ); - - rows.open_at(None); - assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); - } - - #[test] - fn a_thumbnail_that_failed_does_not_get_its_height_back() { - let store = store_with(3); - store.set_thumb(3, Some("no-existe.png"), 2).expect("thumb"); - let rows = open(store, 0); - assert!(rows.row_data(0).is_some_and(|card| !card.has_thumb)); - assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); - - rows.open_at(Some(0)); - rows.open_at(None); - assert_eq!( - rows.span_of(0), - Some((0.0, SIZES.plain)), - "closing it does not give back the height of a thumbnail that is not there" - ); - assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); - } - - #[test] - fn a_row_with_a_thumbnail_does_not_open() { - let store = store_with(2); - store.set_thumb(2, Some("miniatura.png"), 1).expect("thumb"); - let rows = open(store, 0); - rows.open_at(Some(0)); - assert_eq!( - rows.span_of(0), - Some((0.0, SIZES.tall)), - "la miniatura ya ocupa lo suyo" - ); - } - - #[test] - fn revealing_a_row_only_scrolls_when_the_row_is_out_of_sight() { - let viewport = 400.0; - assert_eq!(reveal(0.0, 124.0, 0.0, viewport), 0.0, "ya se ve"); - assert_eq!( - reveal(124.0, 124.0, -124.0, viewport), - -124.0, - "arriba del todo" - ); - assert_eq!( - reveal(124.0, 124.0, -300.0, viewport), - -116.0, - "queda encima: baja con un respiro" - ); - assert_eq!( - reveal(4.0, 124.0, -20.0, viewport), - 0.0, - "arriba del todo no se pasa de largo" - ); - assert_eq!( - reveal(500.0, 124.0, 0.0, viewport), - -232.0, - "queda debajo: sube lo justo" - ); - assert_eq!(reveal(500.0, 124.0, 0.0, 0.0), 0.0, "sin alto no se decide"); - } -} +#[path = "model_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/model_test.rs b/crates/cp-panel/src/model_test.rs new file mode 100644 index 00000000..d928ffa4 --- /dev/null +++ b/crates/cp-panel/src/model_test.rs @@ -0,0 +1,218 @@ +use super::*; + +const SIZES: Metrics = Metrics { + tall: 146.0, + plain: 86.0, + found: 68.0, + frame: 50.0, + line: 18.0, +}; + +fn store_with(count: usize) -> Rc { + let store = Store::in_memory().expect("esquema"); + for at in 0..count { + store + .insert_text(&format!("u{at}"), &format!("elemento {at}"), at as i64) + .expect("insert"); + } + Rc::new(store) +} + +fn open(store: Rc, now: i64) -> Rc { + Rows::open(store, Filter::default(), now, SIZES) +} + +#[test] +fn the_first_page_comes_with_the_model_and_the_rest_waits_for_the_view() { + let rows = open(store_with(300), 1_000); + assert_eq!(rows.row_count(), PAGE); + assert_eq!(rows.loaded(), PAGE); + assert_eq!(rows.row_data(0).map(|card| card.id), Some(300)); + let more = rows.load_page(); + assert_eq!(more, PAGE); + assert_eq!(rows.row_count(), 2 * PAGE); + assert_eq!(rows.load_page(), 60); + assert_eq!(rows.load_page(), 0, "there are no more pages"); + assert_eq!(rows.row_count(), 300); +} + +#[test] +fn a_row_is_built_once_and_read_back_from_the_cache() { + let rows = open(store_with(3), 5_000); + let first = rows.row_data(0).expect("fila"); + assert_eq!(first.body.as_str(), "elemento 2"); + assert_eq!(first.age.as_str(), "ahora"); + assert!(rows.cards.borrow()[0].is_some()); + let again = rows.row_data(0).expect("from the cache"); + assert_eq!((again.id, again.body.as_str()), (first.id, "elemento 2")); + assert_eq!(rows.row_data(9).map(|card| card.id), None); +} + +#[test] +fn the_next_page_is_wanted_only_near_the_end_and_only_once() { + let rows = open(store_with(300), 0); + assert!(!rows.needs_more(0)); + assert!(!rows.needs_more(PAGE - AHEAD - 1)); + assert!(rows.needs_more(PAGE - AHEAD)); + assert!(rows.needs_more(PAGE - 1)); + rows.loading.set(true); + assert!(!rows.needs_more(PAGE - 1), "a page is already on its way"); + rows.loading.set(false); + while rows.load_page() > 0 {} + assert!(!rows.needs_more(299), "no queda nada que pedir"); +} + +#[test] +fn a_row_written_from_the_view_is_what_comes_back() { + let rows = open(store_with(2), 0); + let mut card = rows.row_data(1).expect("fila"); + card.body = "editado".into(); + rows.set_row_data(1, card); + assert_eq!( + rows.row_data(1).map(|c| c.body.to_string()), + Some("editado".into()) + ); + rows.set_row_data(9, Card::default()); + assert_eq!(rows.row_count(), 2, "fuera de rango no crea filas"); +} + +#[test] +fn a_query_that_matches_nothing_is_an_empty_model_not_an_error() { + let filter = Filter { + query: Some("nada-de-esto".into()), + ..Default::default() + }; + let rows = Rows::open(store_with(10), filter, 0, SIZES); + assert_eq!(rows.row_count(), 0); + assert!(rows.exhausted.get()); +} + +#[test] +fn every_row_knows_where_it_starts_and_a_thumbnail_makes_it_taller() { + let store = store_with(3); + store.set_thumb(2, Some("miniatura.png"), 1).expect("thumb"); + let rows = open(store, 0); + assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); + assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.tall))); + assert_eq!( + rows.span_of(2), + Some((SIZES.plain + SIZES.tall, SIZES.plain)) + ); + assert_eq!(rows.span_of(3), None, "no hay cuarta fila"); +} + +#[test] +fn a_thumbnail_that_does_not_load_gives_its_height_back_to_the_rows_below() { + let store = store_with(3); + store.set_thumb(3, Some("no-existe.png"), 2).expect("thumb"); + let rows = open(store, 0); + assert_eq!(rows.span_of(0), Some((0.0, SIZES.tall))); + let card = rows.row_data(0).expect("fila"); + assert!(!card.has_thumb, "the thumbnail is not on disk"); + assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); + assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); +} + +#[test] +fn the_open_row_grows_what_its_text_asks_and_nobody_else_pays() { + let store = Store::in_memory().expect("esquema"); + store.insert_text("u0", "corto", 0).expect("insert"); + store + .insert_text("u1", &"palabra ".repeat(40), 1) + .expect("insert"); + store.insert_text("u2", "otro corto", 2).expect("insert"); + let rows = open(Rc::new(store), 0); + let long = SIZES.frame + 6.0 * SIZES.line; + + assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); + + rows.open_at(Some(1)); + assert_eq!( + rows.span_of(0), + Some((0.0, SIZES.plain)), + "la de arriba no se mueve" + ); + assert_eq!( + rows.span_of(1), + Some((SIZES.plain, long)), + "crece lo que pide su texto" + ); + assert_eq!( + rows.span_of(2), + Some((SIZES.plain + long, SIZES.plain)), + "the one below drops by what the open one grew" + ); + + rows.open_at(Some(2)); + assert_eq!( + rows.span_of(1), + Some((SIZES.plain, SIZES.plain)), + "la anterior vuelve" + ); + assert_eq!( + rows.span_of(2), + Some((2.0 * SIZES.plain, SIZES.plain)), + "un texto corto no gana nada al abrirse" + ); + + rows.open_at(None); + assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); +} + +#[test] +fn a_thumbnail_that_failed_does_not_get_its_height_back() { + let store = store_with(3); + store.set_thumb(3, Some("no-existe.png"), 2).expect("thumb"); + let rows = open(store, 0); + assert!(rows.row_data(0).is_some_and(|card| !card.has_thumb)); + assert_eq!(rows.span_of(0), Some((0.0, SIZES.plain))); + + rows.open_at(Some(0)); + rows.open_at(None); + assert_eq!( + rows.span_of(0), + Some((0.0, SIZES.plain)), + "closing it does not give back the height of a thumbnail that is not there" + ); + assert_eq!(rows.span_of(1), Some((SIZES.plain, SIZES.plain))); +} + +#[test] +fn a_row_with_a_thumbnail_does_not_open() { + let store = store_with(2); + store.set_thumb(2, Some("miniatura.png"), 1).expect("thumb"); + let rows = open(store, 0); + rows.open_at(Some(0)); + assert_eq!( + rows.span_of(0), + Some((0.0, SIZES.tall)), + "la miniatura ya ocupa lo suyo" + ); +} + +#[test] +fn revealing_a_row_only_scrolls_when_the_row_is_out_of_sight() { + let viewport = 400.0; + assert_eq!(reveal(0.0, 124.0, 0.0, viewport), 0.0, "ya se ve"); + assert_eq!( + reveal(124.0, 124.0, -124.0, viewport), + -124.0, + "arriba del todo" + ); + assert_eq!( + reveal(124.0, 124.0, -300.0, viewport), + -116.0, + "queda encima: baja con un respiro" + ); + assert_eq!( + reveal(4.0, 124.0, -20.0, viewport), + 0.0, + "arriba del todo no se pasa de largo" + ); + assert_eq!( + reveal(500.0, 124.0, 0.0, viewport), + -232.0, + "queda debajo: sube lo justo" + ); + assert_eq!(reveal(500.0, 124.0, 0.0, 0.0), 0.0, "sin alto no se decide"); +} diff --git a/crates/cp-panel/src/note.rs b/crates/cp-panel/src/note.rs index 662353cb..e0b8ed42 100644 --- a/crates/cp-panel/src/note.rs +++ b/crates/cp-panel/src/note.rs @@ -69,44 +69,5 @@ fn clock_now() -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_clock_reads_as_hours_minutes_and_seconds() { - let stamp = clock_now(); - assert_eq!(stamp.len(), 8); - assert_eq!(stamp.matches(':').count(), 2); - assert!( - stamp.chars().all(|one| one.is_ascii_digit() || one == ':'), - "{stamp}" - ); - } - - #[test] - fn what_the_log_holds_is_only_readable_by_whoever_copied_it() { - note("any line at all"); - let path = where_to(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path) - .expect("is here") - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600, "the log is nobody else's business"); - } - #[cfg(not(unix))] - assert!(path.exists()); - } - - #[test] - fn the_log_sits_where_the_privacy_note_says_it_does() { - let where_it_is = where_to(); - assert!(where_it_is.starts_with(folder())); - assert_eq!( - where_it_is.file_name().and_then(|name| name.to_str()), - Some("cp-panel.log") - ); - } -} +#[path = "note_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/note_test.rs b/crates/cp-panel/src/note_test.rs new file mode 100644 index 00000000..9c8974d8 --- /dev/null +++ b/crates/cp-panel/src/note_test.rs @@ -0,0 +1,39 @@ +use super::*; + +#[test] +fn the_clock_reads_as_hours_minutes_and_seconds() { + let stamp = clock_now(); + assert_eq!(stamp.len(), 8); + assert_eq!(stamp.matches(':').count(), 2); + assert!( + stamp.chars().all(|one| one.is_ascii_digit() || one == ':'), + "{stamp}" + ); +} + +#[test] +fn what_the_log_holds_is_only_readable_by_whoever_copied_it() { + note("any line at all"); + let path = where_to(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path) + .expect("is here") + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600, "the log is nobody else's business"); + } + #[cfg(not(unix))] + assert!(path.exists()); +} + +#[test] +fn the_log_sits_where_the_privacy_note_says_it_does() { + let where_it_is = where_to(); + assert!(where_it_is.starts_with(folder())); + assert_eq!( + where_it_is.file_name().and_then(|name| name.to_str()), + Some("cp-panel.log") + ); +} diff --git a/crates/cp-panel/src/say.rs b/crates/cp-panel/src/say.rs index ed3357fa..efafb5d9 100644 --- a/crates/cp-panel/src/say.rs +++ b/crates/cp-panel/src/say.rs @@ -34,30 +34,5 @@ pub fn pick_in(english: bool, es: &'static str, en: &'static str) -> &'static st } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn only_a_locale_that_starts_with_en_gets_english() { - assert!(english_for(Some("en"))); - assert!(english_for(Some("en-GB"))); - assert!(english_for(Some("EN-us"))); - assert!(!english_for(Some("es"))); - assert!(!english_for(Some("es-CL"))); - assert!(!english_for(Some("pt-BR"))); - } - - #[test] - fn what_was_chosen_is_what_is_said() { - assert_eq!(pick_in(false, "hola", "hello"), "hola"); - assert_eq!(pick_in(true, "hola", "hello"), "hello"); - } - - #[test] - fn the_panel_speaks_spanish_until_somebody_says_otherwise() { - assert_eq!( - pick("hola", "hello"), - pick_in(in_english(), "hola", "hello") - ); - } -} +#[path = "say_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/say_test.rs b/crates/cp-panel/src/say_test.rs new file mode 100644 index 00000000..9efd9f44 --- /dev/null +++ b/crates/cp-panel/src/say_test.rs @@ -0,0 +1,25 @@ +use super::*; + +#[test] +fn only_a_locale_that_starts_with_en_gets_english() { + assert!(english_for(Some("en"))); + assert!(english_for(Some("en-GB"))); + assert!(english_for(Some("EN-us"))); + assert!(!english_for(Some("es"))); + assert!(!english_for(Some("es-CL"))); + assert!(!english_for(Some("pt-BR"))); +} + +#[test] +fn what_was_chosen_is_what_is_said() { + assert_eq!(pick_in(false, "hola", "hello"), "hola"); + assert_eq!(pick_in(true, "hola", "hello"), "hello"); +} + +#[test] +fn the_panel_speaks_spanish_until_somebody_says_otherwise() { + assert_eq!( + pick("hola", "hello"), + pick_in(in_english(), "hola", "hello") + ); +} diff --git a/crates/cp-panel/src/view.rs b/crates/cp-panel/src/view.rs index 2b9df39a..4bebdf1b 100644 --- a/crates/cp-panel/src/view.rs +++ b/crates/cp-panel/src/view.rs @@ -415,402 +415,5 @@ pub fn dress_words(ui: &crate::Panel) { } #[cfg(test)] -mod tests { - use super::*; - use cp_core::search::{Excerpt, Segment}; - use cp_store::{FoundIn, Snippet}; - - fn row(kind: Option) -> Listed { - Listed { - id: 7, - modified_at: 1_000_000, - created_at: 1_000_000, - kind, - preview: "hola mundo".into(), - app: Some("Mail".into()), - label: None, - color: 0, - thumb_path: None, - paste_count: 3, - last_used_at: None, - broken_since: None, - pinned: true, - snippet: None, - } - } - - #[test] - fn a_row_becomes_the_card_the_panel_draws() { - let card = card_of(&row(Some(Kind::Json)), 1_000_000 + 9 * 60_000); - assert_eq!(card.id, 7); - assert_eq!(card.kind.as_str(), "json"); - assert_eq!(card.title.as_str(), "JSON"); - assert_eq!(card.source.as_str(), "Mail"); - assert_eq!(card.times.as_str(), "3"); - assert_eq!(card.age.as_str(), "9 min"); - assert_eq!(card.body.as_str(), "hola mundo"); - assert!(card.mono); - assert!(!card.has_thumb); - assert!(card.pinned); - } - - #[test] - fn a_search_hit_shows_its_excerpt_instead_of_the_preview() { - let mut hit = row(Some(Kind::Text)); - hit.snippet = Some(Snippet { - found_in: FoundIn::Text, - excerpt: Excerpt { - segments: vec![ - Segment { - text: "…la ".into(), - matched: false, - }, - Segment { - text: "reunión".into(), - matched: true, - }, - Segment { - text: " del jueves…".into(), - matched: false, - }, - ], - }, - }); - let card = card_of(&hit, 2_000_000); - assert_eq!(card.body.as_str(), "…la reunión del jueves…"); - assert!(!card.mono); - assert_eq!(card.times.as_str(), "3"); - } - - #[test] - fn what_was_pasted_once_or_never_carries_no_count() { - let mut once = row(None); - once.paste_count = 1; - once.app = None; - let card = card_of(&once, 1_000_000); - assert_eq!(card.times.as_str(), ""); - assert_eq!(card.source.as_str(), "—"); - assert_eq!(card.title.as_str(), "Texto"); - } - - #[test] - fn every_chip_sits_where_its_count_puts_it() { - let facets = [ - Facet { - kind: Kind::Image, - count: 2, - }, - Facet { - kind: Kind::Text, - count: 3, - }, - Facet { - kind: Kind::Code, - count: 0, - }, - Facet { - kind: Kind::File, - count: 2, - }, - ]; - let chosen = ["text".to_owned()]; - let chips = chips_of(&facets, &chosen); - let keys: Vec<&str> = chips.iter().map(|c| c.key.as_str()).collect(); - assert_eq!( - keys, - ["text", "file", "image"], - "solo tipos, del que más tiene al que menos" - ); - assert!( - chips[0].selected, - "the chosen one is marked wherever it lands" - ); - assert!(!chips[1].selected); - assert_eq!(chips[0].count.as_str(), "3"); - assert!( - chips - .iter() - .all(|one| one.key != "all" && one.key != "pinned"), - "todo y anclados no son tipos y no viven aquí" - ); - - assert!( - chips_of(&[], &[]).is_empty(), - "with no facets there is no row" - ); - } - - fn excerpt_of(parts: &[(&str, bool)]) -> Excerpt { - Excerpt { - segments: parts - .iter() - .map(|(text, matched)| Segment { - text: (*text).into(), - matched: *matched, - }) - .collect(), - } - } - - #[test] - fn the_excerpt_breaks_into_what_goes_before_the_hit_and_what_follows() { - let excerpt = excerpt_of(&[ - ("confirmamos la ", false), - ("reunión", true), - (" del jueves a las 16:30", false), - ]); - assert_eq!( - parts_of(&excerpt), - ( - "confirmamos la ".into(), - "reunión".into(), - " del jueves a las 16:30".into() - ) - ); - } - - #[test] - fn an_excerpt_with_nothing_matched_is_all_text_and_no_hit() { - let (lead, hit, tail) = parts_of(&excerpt_of(&[("sin coincidencias", false)])); - assert_eq!(lead, "sin coincidencias"); - assert!(hit.is_empty(), "with no hit there is nothing to highlight"); - assert!(tail.is_empty()); - } - - #[test] - fn a_long_lead_is_cut_from_the_left_so_the_hit_does_not_fall_off() { - let long = "una entrada muy larga que empuja el término lejos del principio "; - let (lead, hit, _) = parts_of(&excerpt_of(&[(long, false), ("término", true)])); - assert!(lead.starts_with('…'), "it is cut from the left: {lead}"); - assert!(lead.chars().count() <= LEAD + 1); - assert!(lead.ends_with("lejos del principio ")); - assert_eq!(hit, "término"); - } - - #[test] - fn a_row_that_matched_carries_its_three_parts() { - let mut row = row(Some(Kind::Text)); - row.snippet = Some(Snippet { - found_in: FoundIn::Text, - excerpt: excerpt_of(&[("antes de ", false), ("esto", true), (" y después", false)]), - }); - let card = card_of(&row, 1_000_000); - assert!(card.found); - assert_eq!(card.lead.as_str(), "antes de "); - assert_eq!(card.hit.as_str(), "esto"); - assert_eq!(card.tail.as_str(), " y después"); - } - - const EXPIRED: &str = - "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3MDAwMDAwMDAsInN1YiI6ImNwLTMifQ.sig"; - - #[test] - fn a_card_wears_what_is_wrong_with_it() { - let mut broken = row(Some(Kind::File)); - broken.broken_since = Some(10); - assert_eq!(alert_of(&broken, None, 1_000), "No encontrado"); - assert_eq!(badge_of(None), ""); - - let plain = row(Some(Kind::Text)); - assert_eq!(alert_of(&plain, None, 1_000), ""); - - let mut token = row(Some(Kind::Token)); - token.preview = EXPIRED.into(); - let claims = claims_in(&token); - assert_eq!( - badge_of(claims.as_ref()), - "JWT", - "un token legible se marca" - ); - assert_eq!( - alert_of(&token, claims.as_ref(), 1_700_000_001_000), - "CADUCADO", - "pasado el exp, se dice" - ); - assert_eq!( - alert_of(&token, claims.as_ref(), 1_600_000_000_000), - "", - "antes del exp, no" - ); - - let mut fake = row(Some(Kind::Token)); - fake.preview = "no-es-un-token".into(); - assert_eq!( - badge_of(claims_in(&fake).as_ref()), - "", - "lo que no se lee no lleva insignia" - ); - } - - #[test] - fn a_card_asks_for_as_many_lines_as_its_text_needs() { - assert_eq!( - lines_of("#FF8800"), - 2, - "what is short does not grow when it opens" - ); - assert_eq!(lines_of(""), 2); - assert_eq!(lines_of(&"a".repeat(57)), 2); - assert_eq!( - lines_of(&"a".repeat(58)), - 2, - "two lines are still the floor" - ); - assert_eq!(lines_of(&"a".repeat(57 * 3)), 3); - assert_eq!(lines_of(&"a".repeat(57 * 20)), 7, "and there is a ceiling"); - assert_eq!( - lines_of( - "uno -dos -tres -cuatro" - ), - 4, - "los saltos cuentan" - ); - } - - #[test] - fn a_hash_in_the_search_box_becomes_a_kind_filter() { - assert_eq!(sweeten("#ip"), "k:ip"); - assert_eq!(sweeten("#imagen playa"), "k:image playa"); - assert_eq!(sweeten("#Código git"), "k:code git"); - assert_eq!(sweeten("#vídeo"), "k:video"); - assert_eq!(sweeten("reunión jueves"), "reunión jueves"); - assert_eq!( - sweeten("#loquesea"), - "#loquesea", - "lo que no es una clase se busca" - ); - assert_eq!(sweeten("#carpeta #texto"), "k:folder k:text"); - assert_eq!( - sweeten("-#imagen playa"), - "-k:image playa", - "y se puede excluir" - ); - assert_eq!( - sweeten("-reunión"), - "-reunión", - "el menos suelto no se toca" - ); - } - - #[test] - fn every_form_answers_to_its_key() { - for form in Form::ALL { - assert_eq!(form_of(form.as_str()), Some(form)); - } - assert_eq!(form_of("no-existe"), None); - } - - #[test] - fn nothing_the_panel_says_is_left_untranslated() { - for form in Form::ALL { - let (es, en) = form_pair(form); - assert!( - !es.is_empty() && !en.is_empty(), - "{form:?} was left without a name" - ); - } - for kind in [None, Some(Kind::Text), Some(Kind::Code), Some(Kind::Image)] { - let (es, en) = label_pair(kind); - assert!( - !es.is_empty() && !en.is_empty(), - "{kind:?} was left without a name" - ); - } - } - - #[test] - fn what_is_written_in_english_is_not_the_spanish_copied_over() { - let english: Vec<&str> = Form::ALL - .into_iter() - .map(|form| form_pair(form).1) - .collect(); - let shared = Form::ALL - .into_iter() - .filter(|form| { - let (es, en) = form_pair(*form); - es == en - }) - .count(); - assert!(shared <= 3, "too many forms left untranslated: {shared}"); - assert!(english.iter().all(|said| !said.contains('ó'))); - } - - #[test] - fn an_empty_list_explains_itself_in_both_tongues() { - for english in [false, true] { - let (title, hint) = empty_in(english, "", false, false); - assert!(!title.is_empty() && !hint.is_empty()); - let (asked, _) = empty_in(english, "perdido", false, false); - assert!(asked.contains("perdido"), "{asked}"); - } - assert_ne!( - empty_in(false, "", true, false).0, - empty_in(true, "", true, false).0 - ); - } - - #[test] - fn the_count_reads_the_way_each_tongue_counts() { - assert_eq!(count_in(false, 0), "sin elementos"); - assert_eq!(count_in(false, 1), "1 elemento"); - assert_eq!(count_in(false, 7), "7 elementos"); - assert_eq!(count_in(true, 0), "nothing kept"); - assert_eq!(count_in(true, 1), "1 item"); - assert_eq!(count_in(true, 7), "7 items"); - } - - #[test] - fn every_form_has_a_name_for_the_sheet() { - for form in Form::ALL { - assert!(!label_of_form(form).is_empty(), "{form:?} has no name"); - } - } - - #[test] - fn an_empty_list_says_why_it_is_empty() { - assert_eq!( - empty_of("", false, false), - ( - "Todavía no hay nada".into(), - "Lo que copies aparece aquí".into() - ) - ); - assert_eq!(empty_of(" ", true, false).0, "No hay nada anclado"); - assert_eq!(empty_of("", false, true).0, "No hay nada de este tipo"); - - let (title, hint) = empty_of(",", false, false); - assert_eq!(title, "Nada coincide con «,»"); - assert!(hint.contains("por palabras"), "a comma is not a word"); - - let (title, hint) = empty_of("reunion", false, true); - assert_eq!(title, "Nada coincide con «reunion»"); - assert!(hint.contains("quita el filtro")); - - assert_eq!( - empty_of("reunion", false, false).1, - "Prueba con menos letras" - ); - assert_eq!( - empty_of( - "una consulta larguísima que no cabe en la tarjeta", - false, - false - ) - .0, - "Nada coincide con «una consulta larguísima…»" - ); - } - - #[test] - fn big_counts_are_shortened_so_the_pill_stays_a_pill() { - assert_eq!(compact(999), "999"); - assert_eq!(compact(1_250), "1.2k"); - assert_eq!(compact(50_000), "50k"); - assert_eq!(count_text(0), "sin elementos"); - assert_eq!(count_text(1), "1 elemento"); - assert_eq!(count_text(50_000), "50000 elementos"); - } -} +#[path = "view_test.rs"] +mod tests; diff --git a/crates/cp-panel/src/view_test.rs b/crates/cp-panel/src/view_test.rs new file mode 100644 index 00000000..3b90719d --- /dev/null +++ b/crates/cp-panel/src/view_test.rs @@ -0,0 +1,397 @@ +use super::*; +use cp_core::search::{Excerpt, Segment}; +use cp_store::{FoundIn, Snippet}; + +fn row(kind: Option) -> Listed { + Listed { + id: 7, + modified_at: 1_000_000, + created_at: 1_000_000, + kind, + preview: "hola mundo".into(), + app: Some("Mail".into()), + label: None, + color: 0, + thumb_path: None, + paste_count: 3, + last_used_at: None, + broken_since: None, + pinned: true, + snippet: None, + } +} + +#[test] +fn a_row_becomes_the_card_the_panel_draws() { + let card = card_of(&row(Some(Kind::Json)), 1_000_000 + 9 * 60_000); + assert_eq!(card.id, 7); + assert_eq!(card.kind.as_str(), "json"); + assert_eq!(card.title.as_str(), "JSON"); + assert_eq!(card.source.as_str(), "Mail"); + assert_eq!(card.times.as_str(), "3"); + assert_eq!(card.age.as_str(), "9 min"); + assert_eq!(card.body.as_str(), "hola mundo"); + assert!(card.mono); + assert!(!card.has_thumb); + assert!(card.pinned); +} + +#[test] +fn a_search_hit_shows_its_excerpt_instead_of_the_preview() { + let mut hit = row(Some(Kind::Text)); + hit.snippet = Some(Snippet { + found_in: FoundIn::Text, + excerpt: Excerpt { + segments: vec![ + Segment { + text: "…la ".into(), + matched: false, + }, + Segment { + text: "reunión".into(), + matched: true, + }, + Segment { + text: " del jueves…".into(), + matched: false, + }, + ], + }, + }); + let card = card_of(&hit, 2_000_000); + assert_eq!(card.body.as_str(), "…la reunión del jueves…"); + assert!(!card.mono); + assert_eq!(card.times.as_str(), "3"); +} + +#[test] +fn what_was_pasted_once_or_never_carries_no_count() { + let mut once = row(None); + once.paste_count = 1; + once.app = None; + let card = card_of(&once, 1_000_000); + assert_eq!(card.times.as_str(), ""); + assert_eq!(card.source.as_str(), "—"); + assert_eq!(card.title.as_str(), "Texto"); +} + +#[test] +fn every_chip_sits_where_its_count_puts_it() { + let facets = [ + Facet { + kind: Kind::Image, + count: 2, + }, + Facet { + kind: Kind::Text, + count: 3, + }, + Facet { + kind: Kind::Code, + count: 0, + }, + Facet { + kind: Kind::File, + count: 2, + }, + ]; + let chosen = ["text".to_owned()]; + let chips = chips_of(&facets, &chosen); + let keys: Vec<&str> = chips.iter().map(|c| c.key.as_str()).collect(); + assert_eq!( + keys, + ["text", "file", "image"], + "solo tipos, del que más tiene al que menos" + ); + assert!( + chips[0].selected, + "the chosen one is marked wherever it lands" + ); + assert!(!chips[1].selected); + assert_eq!(chips[0].count.as_str(), "3"); + assert!( + chips + .iter() + .all(|one| one.key != "all" && one.key != "pinned"), + "todo y anclados no son tipos y no viven aquí" + ); + + assert!( + chips_of(&[], &[]).is_empty(), + "with no facets there is no row" + ); +} + +fn excerpt_of(parts: &[(&str, bool)]) -> Excerpt { + Excerpt { + segments: parts + .iter() + .map(|(text, matched)| Segment { + text: (*text).into(), + matched: *matched, + }) + .collect(), + } +} + +#[test] +fn the_excerpt_breaks_into_what_goes_before_the_hit_and_what_follows() { + let excerpt = excerpt_of(&[ + ("confirmamos la ", false), + ("reunión", true), + (" del jueves a las 16:30", false), + ]); + assert_eq!( + parts_of(&excerpt), + ( + "confirmamos la ".into(), + "reunión".into(), + " del jueves a las 16:30".into() + ) + ); +} + +#[test] +fn an_excerpt_with_nothing_matched_is_all_text_and_no_hit() { + let (lead, hit, tail) = parts_of(&excerpt_of(&[("sin coincidencias", false)])); + assert_eq!(lead, "sin coincidencias"); + assert!(hit.is_empty(), "with no hit there is nothing to highlight"); + assert!(tail.is_empty()); +} + +#[test] +fn a_long_lead_is_cut_from_the_left_so_the_hit_does_not_fall_off() { + let long = "una entrada muy larga que empuja el término lejos del principio "; + let (lead, hit, _) = parts_of(&excerpt_of(&[(long, false), ("término", true)])); + assert!(lead.starts_with('…'), "it is cut from the left: {lead}"); + assert!(lead.chars().count() <= LEAD + 1); + assert!(lead.ends_with("lejos del principio ")); + assert_eq!(hit, "término"); +} + +#[test] +fn a_row_that_matched_carries_its_three_parts() { + let mut row = row(Some(Kind::Text)); + row.snippet = Some(Snippet { + found_in: FoundIn::Text, + excerpt: excerpt_of(&[("antes de ", false), ("esto", true), (" y después", false)]), + }); + let card = card_of(&row, 1_000_000); + assert!(card.found); + assert_eq!(card.lead.as_str(), "antes de "); + assert_eq!(card.hit.as_str(), "esto"); + assert_eq!(card.tail.as_str(), " y después"); +} + +const EXPIRED: &str = + "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJleHAiOjE3MDAwMDAwMDAsInN1YiI6ImNwLTMifQ.sig"; + +#[test] +fn a_card_wears_what_is_wrong_with_it() { + let mut broken = row(Some(Kind::File)); + broken.broken_since = Some(10); + assert_eq!(alert_of(&broken, None, 1_000), "No encontrado"); + assert_eq!(badge_of(None), ""); + + let plain = row(Some(Kind::Text)); + assert_eq!(alert_of(&plain, None, 1_000), ""); + + let mut token = row(Some(Kind::Token)); + token.preview = EXPIRED.into(); + let claims = claims_in(&token); + assert_eq!( + badge_of(claims.as_ref()), + "JWT", + "un token legible se marca" + ); + assert_eq!( + alert_of(&token, claims.as_ref(), 1_700_000_001_000), + "CADUCADO", + "pasado el exp, se dice" + ); + assert_eq!( + alert_of(&token, claims.as_ref(), 1_600_000_000_000), + "", + "antes del exp, no" + ); + + let mut fake = row(Some(Kind::Token)); + fake.preview = "no-es-un-token".into(); + assert_eq!( + badge_of(claims_in(&fake).as_ref()), + "", + "lo que no se lee no lleva insignia" + ); +} + +#[test] +fn a_card_asks_for_as_many_lines_as_its_text_needs() { + assert_eq!( + lines_of("#FF8800"), + 2, + "what is short does not grow when it opens" + ); + assert_eq!(lines_of(""), 2); + assert_eq!(lines_of(&"a".repeat(57)), 2); + assert_eq!( + lines_of(&"a".repeat(58)), + 2, + "two lines are still the floor" + ); + assert_eq!(lines_of(&"a".repeat(57 * 3)), 3); + assert_eq!(lines_of(&"a".repeat(57 * 20)), 7, "and there is a ceiling"); + assert_eq!( + lines_of( + "uno +dos +tres +cuatro" + ), + 4, + "los saltos cuentan" + ); +} + +#[test] +fn a_hash_in_the_search_box_becomes_a_kind_filter() { + assert_eq!(sweeten("#ip"), "k:ip"); + assert_eq!(sweeten("#imagen playa"), "k:image playa"); + assert_eq!(sweeten("#Código git"), "k:code git"); + assert_eq!(sweeten("#vídeo"), "k:video"); + assert_eq!(sweeten("reunión jueves"), "reunión jueves"); + assert_eq!( + sweeten("#loquesea"), + "#loquesea", + "lo que no es una clase se busca" + ); + assert_eq!(sweeten("#carpeta #texto"), "k:folder k:text"); + assert_eq!( + sweeten("-#imagen playa"), + "-k:image playa", + "y se puede excluir" + ); + assert_eq!( + sweeten("-reunión"), + "-reunión", + "el menos suelto no se toca" + ); +} + +#[test] +fn every_form_answers_to_its_key() { + for form in Form::ALL { + assert_eq!(form_of(form.as_str()), Some(form)); + } + assert_eq!(form_of("no-existe"), None); +} + +#[test] +fn nothing_the_panel_says_is_left_untranslated() { + for form in Form::ALL { + let (es, en) = form_pair(form); + assert!( + !es.is_empty() && !en.is_empty(), + "{form:?} was left without a name" + ); + } + for kind in [None, Some(Kind::Text), Some(Kind::Code), Some(Kind::Image)] { + let (es, en) = label_pair(kind); + assert!( + !es.is_empty() && !en.is_empty(), + "{kind:?} was left without a name" + ); + } +} + +#[test] +fn what_is_written_in_english_is_not_the_spanish_copied_over() { + let english: Vec<&str> = Form::ALL + .into_iter() + .map(|form| form_pair(form).1) + .collect(); + let shared = Form::ALL + .into_iter() + .filter(|form| { + let (es, en) = form_pair(*form); + es == en + }) + .count(); + assert!(shared <= 3, "too many forms left untranslated: {shared}"); + assert!(english.iter().all(|said| !said.contains('ó'))); +} + +#[test] +fn an_empty_list_explains_itself_in_both_tongues() { + for english in [false, true] { + let (title, hint) = empty_in(english, "", false, false); + assert!(!title.is_empty() && !hint.is_empty()); + let (asked, _) = empty_in(english, "perdido", false, false); + assert!(asked.contains("perdido"), "{asked}"); + } + assert_ne!( + empty_in(false, "", true, false).0, + empty_in(true, "", true, false).0 + ); +} + +#[test] +fn the_count_reads_the_way_each_tongue_counts() { + assert_eq!(count_in(false, 0), "sin elementos"); + assert_eq!(count_in(false, 1), "1 elemento"); + assert_eq!(count_in(false, 7), "7 elementos"); + assert_eq!(count_in(true, 0), "nothing kept"); + assert_eq!(count_in(true, 1), "1 item"); + assert_eq!(count_in(true, 7), "7 items"); +} + +#[test] +fn every_form_has_a_name_for_the_sheet() { + for form in Form::ALL { + assert!(!label_of_form(form).is_empty(), "{form:?} has no name"); + } +} + +#[test] +fn an_empty_list_says_why_it_is_empty() { + assert_eq!( + empty_of("", false, false), + ( + "Todavía no hay nada".into(), + "Lo que copies aparece aquí".into() + ) + ); + assert_eq!(empty_of(" ", true, false).0, "No hay nada anclado"); + assert_eq!(empty_of("", false, true).0, "No hay nada de este tipo"); + + let (title, hint) = empty_of(",", false, false); + assert_eq!(title, "Nada coincide con «,»"); + assert!(hint.contains("por palabras"), "a comma is not a word"); + + let (title, hint) = empty_of("reunion", false, true); + assert_eq!(title, "Nada coincide con «reunion»"); + assert!(hint.contains("quita el filtro")); + + assert_eq!( + empty_of("reunion", false, false).1, + "Prueba con menos letras" + ); + assert_eq!( + empty_of( + "una consulta larguísima que no cabe en la tarjeta", + false, + false + ) + .0, + "Nada coincide con «una consulta larguísima…»" + ); +} + +#[test] +fn big_counts_are_shortened_so_the_pill_stays_a_pill() { + assert_eq!(compact(999), "999"); + assert_eq!(compact(1_250), "1.2k"); + assert_eq!(compact(50_000), "50k"); + assert_eq!(count_text(0), "sin elementos"); + assert_eq!(count_text(1), "1 elemento"); + assert_eq!(count_text(50_000), "50000 elementos"); +} From b086c541124581955b34ca69c2bfc34479a5f8d5 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 20:04:08 -0300 Subject: [PATCH 5/9] fix(ci): two crates had an example of the same name, and they overwrote each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on Windows stopped with `LNK1104: cannot open file target\debug\examples\twice.exe` while linking cp-mac's example. The file was open: cp-win was linking its own `twice` into it at that moment. cargo puts every example at `target//examples/`, with no room for the package it came from, so `cp-mac/examples/probe.rs` and `cp-win/examples/probe.rs` are one binary, and so are the two `twice.rs`. Proven here: building cp-win's `twice` and then cp-mac's leaves one `target/debug/examples/twice.exe`, and the second silently replaces the first. Under `cargo test --no-run --workspace` the two link steps run at once, and on Windows the loser cannot open a file the winner holds. This has been true since both crates existed and nothing had caught it: whether it fails depends on whether the two links overlap in time, and until now they had not. Nothing about this branch caused it — moving 94 test modules into siblings changed how the compilation units interleave, which is enough to lose a race that was always there. So the examples get names of their own — `mac_probe`, `mac_twice`, `win_probe`, `win_twice` — which also says which platform each one is for at a glance. The `probe/` directory beside them does not move, so `include!("probe/battery.rs")` and the two lines in .github/oversized.txt still point where they did. The four `--example probe` invocations in ci.yml follow the rename. Renaming one side would have been enough to break the collision, and both are renamed because one bare `probe` next to a `mac_probe` invites the next person to guess which is which. --- .github/workflows/ci.yml | 8 ++++---- crates/cp-mac/examples/{probe.rs => mac_probe.rs} | 0 crates/cp-mac/examples/{twice.rs => mac_twice.rs} | 0 crates/cp-win/examples/{probe.rs => win_probe.rs} | 0 crates/cp-win/examples/{twice.rs => win_twice.rs} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename crates/cp-mac/examples/{probe.rs => mac_probe.rs} (100%) rename crates/cp-mac/examples/{twice.rs => mac_twice.rs} (100%) rename crates/cp-win/examples/{probe.rs => win_probe.rs} (100%) rename crates/cp-win/examples/{twice.rs => win_twice.rs} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b93f41f..77a9bfcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,13 +44,13 @@ jobs: run: cargo test --doc --workspace - if: runner.os == 'macOS' - run: cargo run -p cp-mac --example probe + run: cargo run -p cp-mac --example mac_probe - name: The probe reads a real clipboard, so leave something on it if: runner.os == 'Windows' shell: pwsh run: Set-Clipboard -Value "cp-probe" - if: runner.os == 'Windows' - run: cargo run -p cp-win --example probe + run: cargo run -p cp-win --example win_probe lint: name: fmt @@ -143,7 +143,7 @@ jobs: : > "app/src-tauri/binaries/cp-panel-$triple" - run: cargo llvm-cov --no-report --workspace - - run: cargo llvm-cov --no-report run -p cp-mac --example probe + - run: cargo llvm-cov --no-report run -p cp-mac --example mac_probe - run: cargo llvm-cov report --lcov --output-path lcov-rust.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' - run: cargo llvm-cov report --fail-under-lines 90 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' @@ -191,7 +191,7 @@ jobs: - name: The probe reads a real clipboard, so leave something on it shell: pwsh run: Set-Clipboard -Value "cp-probe" - - run: cargo llvm-cov --no-report run -p cp-win --example probe + - run: cargo llvm-cov --no-report run -p cp-win --example win_probe - run: cargo llvm-cov report --lcov --output-path lcov-win.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' - run: cargo llvm-cov report --fail-under-lines 95 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' - name: Every path is relative to the repository, which is what Sonar reads diff --git a/crates/cp-mac/examples/probe.rs b/crates/cp-mac/examples/mac_probe.rs similarity index 100% rename from crates/cp-mac/examples/probe.rs rename to crates/cp-mac/examples/mac_probe.rs diff --git a/crates/cp-mac/examples/twice.rs b/crates/cp-mac/examples/mac_twice.rs similarity index 100% rename from crates/cp-mac/examples/twice.rs rename to crates/cp-mac/examples/mac_twice.rs diff --git a/crates/cp-win/examples/probe.rs b/crates/cp-win/examples/win_probe.rs similarity index 100% rename from crates/cp-win/examples/probe.rs rename to crates/cp-win/examples/win_probe.rs diff --git a/crates/cp-win/examples/twice.rs b/crates/cp-win/examples/win_twice.rs similarity index 100% rename from crates/cp-win/examples/twice.rs rename to crates/cp-win/examples/win_twice.rs From 9f50b32ac7b58ec0569b5f8a7f1699339f782145 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 23:18:44 -0300 Subject: [PATCH 6/9] fix(rules): the ceiling had a back door, and its brace counter could not read Rust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two lenses over this branch found the same two holes, and both are now cases that fail on purpose. **The exemption I added was a back door.** `only_tests()` looked for the `#[path]` anywhere in a window of three lines after a `#[cfg(test)]`, without checking they belonged to the same item. 1552 lines of production code went invisible in eight different ways: a stray `#[cfg(test)] const` three lines above a production `mod`, a `#[path]` inside a comment, a `#[path]` inside a raw string, a file exempting itself, a `..` crossing into another crate, and — worst — a `#[cfg(test)]` in a `.rs` that nothing compiles, outside `src/`, where neither clippy nor rustfmt nor the no-comments rule could ever refute it. The shape that opens it already exists in the tree at `crates/cp-panel/src/engine.rs`. It now takes the exact three lines, in order, with nothing between them: the `cfg`, then `#[path = ".rs"]` with a bare name and no separators, then `mod ;`. A file cannot exempt itself, and only files the measurer already reads can exempt anything, so an exemption always sits somewhere a compiler and a formatter can see. Twelve cases: the eight attacks are refused and the four legitimate shapes still work — including `all(windows, test)` and `any(test, feature = "z")`, which the old window rejected by accident of order. **And the brace counter never understood Rust.** `bare_of` neutralised `"…"` and `'x'` but not `r#"…"#`, so a raw string with an odd number of quotes inside desynchronised the rest of the line. This is not hypothetical: measuring `origin/main` with this branch's script, `crates/cp-core/src/kind.rs` reads 323 lines where it should read 304, and the measurer hands a test function (`one_marker_alone_is_not_code`) to production. The line is `assert!(balanced(r#"{"\""}"#), …)` — it counts −1 braces, so `mod borders` closes 19 lines early and the rest is counted as code. The mirror image is the dangerous one, and the lens proved it: a file of 1612 lines, 1604 of them production, measures **0** and passes the ceiling. Rather than teach the counter about raw strings, the block-skipping is gone. It existed to subtract inline test modules, and after this branch there are none: `code_of` is now the line count of the file, the way Tisty measures. What made that safe is a new rule — a test module written inside a file of code fails CI, so the case the skipping handled cannot come back. The whole class of brace-counting bugs goes with it. The counted numbers move, and upwards, because the `#[cfg(test)]` helpers that stayed in their files now count as what they are — lines in a file of code: `store.rs` 1409 → 1457, `paste_as.rs` 1363 → 1366. Nothing crosses the ceiling and the three lines in .github/oversized.txt still hold. A second rule closes the class that `b086c54` only closed one case of: two crates may not name an example the same, because cargo links every example to `target//examples/` and the two link steps race. --- .github/workflows/rules.yml | 28 +++++ ...ing_test_macos.rs => waking_macos_test.rs} | 0 ...test_windows.rs => waking_windows_test.rs} | 0 ...b_properties.rs => dib_properties_test.rs} | 0 ...operties.rs => formats_properties_test.rs} | 0 ...needs.rs => formats_windows_needs_test.rs} | 0 ..._properties.rs => item_properties_test.rs} | 0 .../{kind_borders.rs => kind_borders_test.rs} | 0 ...m_2x.rs => kind_inherited_from_2x_test.rs} | 0 ..._redundancy.rs => kind_redundancy_test.rs} | 0 ...properties.rs => paste_properties_test.rs} | 0 ...h_insisting.rs => watch_insisting_test.rs} | 0 ...properties.rs => watch_properties_test.rs} | 0 ...unter.rs => watch_windows_counter_test.rs} | 0 ...ekeeping.rs => store_housekeeping_test.rs} | 0 ...ore_identity.rs => store_identity_test.rs} | 0 ...store_listing.rs => store_listing_test.rs} | 0 scripts/oversized.py | 100 ++++++------------ 18 files changed, 60 insertions(+), 68 deletions(-) rename app/src-tauri/src/{waking_test_macos.rs => waking_macos_test.rs} (100%) rename app/src-tauri/src/{waking_test_windows.rs => waking_windows_test.rs} (100%) rename crates/cp-core/src/{dib_properties.rs => dib_properties_test.rs} (100%) rename crates/cp-core/src/{formats_properties.rs => formats_properties_test.rs} (100%) rename crates/cp-core/src/{formats_windows_needs.rs => formats_windows_needs_test.rs} (100%) rename crates/cp-core/src/{item_properties.rs => item_properties_test.rs} (100%) rename crates/cp-core/src/{kind_borders.rs => kind_borders_test.rs} (100%) rename crates/cp-core/src/{kind_inherited_from_2x.rs => kind_inherited_from_2x_test.rs} (100%) rename crates/cp-core/src/{kind_redundancy.rs => kind_redundancy_test.rs} (100%) rename crates/cp-core/src/{paste_properties.rs => paste_properties_test.rs} (100%) rename crates/cp-core/src/{watch_insisting.rs => watch_insisting_test.rs} (100%) rename crates/cp-core/src/{watch_properties.rs => watch_properties_test.rs} (100%) rename crates/cp-core/src/{watch_windows_counter.rs => watch_windows_counter_test.rs} (100%) rename crates/cp-store/src/{store_housekeeping.rs => store_housekeeping_test.rs} (100%) rename crates/cp-store/src/{store_identity.rs => store_identity_test.rs} (100%) rename crates/cp-store/src/{store_listing.rs => store_listing_test.rs} (100%) diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index 61553ba9..d85435d9 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -120,6 +120,34 @@ jobs: echo "::error::no block comments either"; exit 1 fi + - name: The tests live beside the file, not inside it + run: | + found=$(grep -rnA1 -E '^[[:space:]]*#\[cfg\([^)]*\btest\b' \ + crates app/src-tauri/src --include='*.rs' \ + | grep -E '^[^:]+[:-][0-9]+[:-][[:space:]]*mod [a-z_0-9]+ \{' || true) + if [ -n "$found" ]; then + echo "$found" + echo "::error::a test module goes in a file of its own beside the one it tests, \ + declared as «#[cfg(test)] #[path = \"_test.rs\"] mod tests;». Inside the \ + file it buries the code and the ceiling stops measuring what a reader has to \ + scroll past." + exit 1 + fi + echo "no test module is buried in a file of code" + + - name: No two crates name an example the same + run: | + said=$(find crates -mindepth 3 -maxdepth 3 -path '*/examples/*.rs' \ + -exec basename {} .rs \; | sort) + twice=$(printf '%s\n' "$said" | uniq -d) + if [ -n "$twice" ]; then + echo "::error::two crates name an example the same, and cargo links every example to \ + target//examples/, so they are one binary and the two link steps \ + race: $(echo "$twice" | tr '\n' ' ')" + exit 1 + fi + echo "every example has a name of its own: $(echo "$said" | tr '\n' ' ')" + - name: No file or function grows past what a person can hold run: python3 scripts/oversized.py diff --git a/app/src-tauri/src/waking_test_macos.rs b/app/src-tauri/src/waking_macos_test.rs similarity index 100% rename from app/src-tauri/src/waking_test_macos.rs rename to app/src-tauri/src/waking_macos_test.rs diff --git a/app/src-tauri/src/waking_test_windows.rs b/app/src-tauri/src/waking_windows_test.rs similarity index 100% rename from app/src-tauri/src/waking_test_windows.rs rename to app/src-tauri/src/waking_windows_test.rs diff --git a/crates/cp-core/src/dib_properties.rs b/crates/cp-core/src/dib_properties_test.rs similarity index 100% rename from crates/cp-core/src/dib_properties.rs rename to crates/cp-core/src/dib_properties_test.rs diff --git a/crates/cp-core/src/formats_properties.rs b/crates/cp-core/src/formats_properties_test.rs similarity index 100% rename from crates/cp-core/src/formats_properties.rs rename to crates/cp-core/src/formats_properties_test.rs diff --git a/crates/cp-core/src/formats_windows_needs.rs b/crates/cp-core/src/formats_windows_needs_test.rs similarity index 100% rename from crates/cp-core/src/formats_windows_needs.rs rename to crates/cp-core/src/formats_windows_needs_test.rs diff --git a/crates/cp-core/src/item_properties.rs b/crates/cp-core/src/item_properties_test.rs similarity index 100% rename from crates/cp-core/src/item_properties.rs rename to crates/cp-core/src/item_properties_test.rs diff --git a/crates/cp-core/src/kind_borders.rs b/crates/cp-core/src/kind_borders_test.rs similarity index 100% rename from crates/cp-core/src/kind_borders.rs rename to crates/cp-core/src/kind_borders_test.rs diff --git a/crates/cp-core/src/kind_inherited_from_2x.rs b/crates/cp-core/src/kind_inherited_from_2x_test.rs similarity index 100% rename from crates/cp-core/src/kind_inherited_from_2x.rs rename to crates/cp-core/src/kind_inherited_from_2x_test.rs diff --git a/crates/cp-core/src/kind_redundancy.rs b/crates/cp-core/src/kind_redundancy_test.rs similarity index 100% rename from crates/cp-core/src/kind_redundancy.rs rename to crates/cp-core/src/kind_redundancy_test.rs diff --git a/crates/cp-core/src/paste_properties.rs b/crates/cp-core/src/paste_properties_test.rs similarity index 100% rename from crates/cp-core/src/paste_properties.rs rename to crates/cp-core/src/paste_properties_test.rs diff --git a/crates/cp-core/src/watch_insisting.rs b/crates/cp-core/src/watch_insisting_test.rs similarity index 100% rename from crates/cp-core/src/watch_insisting.rs rename to crates/cp-core/src/watch_insisting_test.rs diff --git a/crates/cp-core/src/watch_properties.rs b/crates/cp-core/src/watch_properties_test.rs similarity index 100% rename from crates/cp-core/src/watch_properties.rs rename to crates/cp-core/src/watch_properties_test.rs diff --git a/crates/cp-core/src/watch_windows_counter.rs b/crates/cp-core/src/watch_windows_counter_test.rs similarity index 100% rename from crates/cp-core/src/watch_windows_counter.rs rename to crates/cp-core/src/watch_windows_counter_test.rs diff --git a/crates/cp-store/src/store_housekeeping.rs b/crates/cp-store/src/store_housekeeping_test.rs similarity index 100% rename from crates/cp-store/src/store_housekeeping.rs rename to crates/cp-store/src/store_housekeeping_test.rs diff --git a/crates/cp-store/src/store_identity.rs b/crates/cp-store/src/store_identity_test.rs similarity index 100% rename from crates/cp-store/src/store_identity.rs rename to crates/cp-store/src/store_identity_test.rs diff --git a/crates/cp-store/src/store_listing.rs b/crates/cp-store/src/store_listing_test.rs similarity index 100% rename from crates/cp-store/src/store_listing.rs rename to crates/cp-store/src/store_listing_test.rs diff --git a/scripts/oversized.py b/scripts/oversized.py index eee9faa9..1667fd51 100644 --- a/scripts/oversized.py +++ b/scripts/oversized.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -import os import pathlib import re import sys @@ -13,8 +12,9 @@ QUOTED = re.compile(r'"(?:[^"\\]|\\.)*"') CHARRED = re.compile(r"'(?:[^'\\]|\\.)'") NOTED = re.compile(r"//.*$") -ONLY_IN_TESTS = re.compile(r"#\[cfg\((all\()?\s*test\b") -DECLARED = re.compile(r'#\[path\s*=\s*"([^"]+)"\]') +GATED = re.compile(r"^#\[cfg\([^)]*\btest\b") +DECLARED = re.compile(r'^#\[path = "(?P[A-Za-z0-9_]+\.rs)"\]$') +A_CHILD = re.compile(r"^mod \w+;$") HEADED = re.compile( r"(?P\s*)(pub(\([^)]*\))? )?(default )?(const )?(async )?(unsafe )?" r'(extern "[A-Za-z-]+" )?fn (?P\w+)' @@ -25,61 +25,23 @@ def bare_of(line: str) -> str: return NOTED.sub("", CHARRED.sub("''", QUOTED.sub('""', line))) -def braces(line: str) -> int: - bare = bare_of(line) - return bare.count("{") - bare.count("}") - - -def block_ends(lines: list[str], from_turn: int) -> int: - last = len(lines) - ahead = from_turn - while ahead < last and not lines[ahead].strip(): - ahead += 1 - depth, opened = 0, False - while ahead < last: - bare = bare_of(lines[ahead]) - depth += braces(lines[ahead]) - opened = opened or "{" in bare - if opened and depth <= 0: - break - if not opened and bare.rstrip().endswith(";"): - break - ahead += 1 - return ahead - - -def blanked(at: pathlib.Path) -> list[str | None]: - lines = at.read_text(encoding="utf-8", errors="replace").splitlines() - kept: list[str | None] = list(lines) - turn, last = 0, len(lines) - while turn < last: - if not ONLY_IN_TESTS.match(lines[turn].strip()): - turn += 1 - continue - ends = block_ends(lines, turn + 1) - for each in range(turn, min(ends + 1, last)): - kept[each] = None - turn = ends + 1 - return kept +def lines_of(at: pathlib.Path) -> list[str]: + return at.read_text(encoding="utf-8", errors="replace").splitlines() def code_of(at: pathlib.Path) -> int: - if at.suffix != ".rs": - return len(at.read_text(encoding="utf-8", errors="replace").splitlines()) - return sum(1 for line in blanked(at) if line is not None) + return len(lines_of(at)) def held_of(at: pathlib.Path) -> dict[str, int]: if at.suffix != ".rs": return {} - lines = blanked(at) + lines = lines_of(at) found: dict[str, int] = {} open_at: list[tuple[str, int, int]] = [] waiting: tuple[str, int] | None = None depth = 0 for turn, line in enumerate(lines): - if line is None: - continue bare = bare_of(line) if waiting is None: said = HEADED.match(line) @@ -104,28 +66,8 @@ def held_of(at: pathlib.Path) -> dict[str, int]: return found -def only_tests() -> set[str]: - said = set() - for where in WHERE: - base = pathlib.Path(where) - if not base.is_dir(): - continue - for at in base.rglob("*.rs"): - lines = at.read_text(encoding="utf-8", errors="replace").splitlines() - for turn, line in enumerate(lines): - if not ONLY_IN_TESTS.match(line.strip()): - continue - for ahead in lines[turn + 1:turn + 4]: - found = DECLARED.search(ahead) - if found: - said.add(pathlib.Path(os.path.normpath(at.parent / found.group(1))).as_posix()) - break - return said - - -def reaching() -> list[pathlib.Path]: +def candidates() -> list[pathlib.Path]: every = [] - apart = only_tests() for where in WHERE: base = pathlib.Path(where) if not base.is_dir(): @@ -136,8 +78,6 @@ def reaching() -> list[pathlib.Path]: said = at.as_posix() if "node_modules" in said or "/tests/" in said or ".test." in said: continue - if said in apart: - continue if any(said.endswith(one) for one in TABLES): continue if where == "crates" and "/src/" not in said and "/examples/" not in said: @@ -146,6 +86,30 @@ def reaching() -> list[pathlib.Path]: return every +def only_tests(among: list[pathlib.Path]) -> set[str]: + said = set() + for at in among: + if at.suffix != ".rs": + continue + lines = [one.strip() for one in lines_of(at)] + for turn in range(len(lines) - 2): + if not GATED.match(lines[turn]): + continue + named = DECLARED.match(lines[turn + 1]) + if not named or not A_CHILD.match(lines[turn + 2]): + continue + beside = at.with_name(named.group("file")) + if beside != at: + said.add(beside.as_posix()) + return said + + +def reaching() -> list[pathlib.Path]: + among = candidates() + apart = only_tests(among) + return [at for at in among if at.as_posix() not in apart] + + def measured() -> dict[str, int]: found = {} for at in reaching(): From 74783cce9277af420d8a38d0cd1954a9b6fa9e76 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 23:19:52 -0300 Subject: [PATCH 7/9] refactor(rules): every sibling ends in _test.rs, so a tool can tell a test from code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixteen of the 94 siblings carried the name of a production module — `store_listing.rs`, `kind_borders.rs`, `dib_properties.rs`, `watch_insisting.rs` — because that is what the module inside them is called. It reads well and it cost two things. **The next split of `store.rs` was aimed straight at them.** That file measures 1457 against a ceiling of 1500: 43 lines of room. Whoever has to split it will reach for `store_listing.rs`, `store_housekeeping.rs` and `store_identity.rs`, and all three already exist and are exempt from the ceiling. Either they pick a worse name and live with `store_listing.rs` (tests) beside `store_lists.rs` (code), or they put production in an exempt file and it stays unmeasured for good, because `store.rs` still declares it. **And no tool could tell them apart.** The only thing that knows which files are tests is the measurer reading the `#[path]` declarations; every other consumer — `--ignore-filename-regex`, `sonar.test.inclusions`, a future `exclude_globs` — can only match names. With these sixteen, matching `*_test.rs` would have classified fourteen test files as production, silently. So the name says which module it is *and* ends in `_test.rs`: `store_listing_test.rs`, `kind_inherited_from_2x_test.rs`, `waking_windows_test.rs`. Tisty names them descriptively and has the same latent trap; this diverges from it on purpose. Nothing moved but the names and the sixteen `#[path]` lines that point at them. 853 tests. Two `#[cfg(test)]` helpers also go where they belong. `ansi_drop_of` in `crates/cp-win/src/drop.rs` was 13 lines of test code sitting in a file of production, `pub(crate)` for a caller that is now its own child module, and its only user is `drop_test.rs`. `A_DAY` in `crates/cp-panel/src/engine.rs` is used only by `engine_test.rs`, and its twin in cp-store already lives inside the sibling. The other four stray helpers stay where they are: each is used by three or four different siblings, so the parent is the right place. --- app/src-tauri/src/waking.rs | 4 ++-- crates/cp-core/src/dib.rs | 2 +- crates/cp-core/src/formats.rs | 4 ++-- crates/cp-core/src/item.rs | 2 +- crates/cp-core/src/kind.rs | 6 +++--- crates/cp-core/src/paste.rs | 2 +- crates/cp-core/src/watch.rs | 6 +++--- crates/cp-panel/src/engine.rs | 2 -- crates/cp-panel/src/engine_test.rs | 2 ++ crates/cp-store/src/store.rs | 6 +++--- crates/cp-win/src/drop.rs | 14 -------------- crates/cp-win/src/drop_test.rs | 13 +++++++++++++ 12 files changed, 31 insertions(+), 32 deletions(-) diff --git a/app/src-tauri/src/waking.rs b/app/src-tauri/src/waking.rs index 33dd5861..78ddad94 100644 --- a/app/src-tauri/src/waking.rs +++ b/app/src-tauri/src/waking.rs @@ -221,9 +221,9 @@ mod there { } #[cfg(all(test, windows))] -#[path = "waking_test_windows.rs"] +#[path = "waking_windows_test.rs"] mod tests; #[cfg(all(test, target_os = "macos"))] -#[path = "waking_test_macos.rs"] +#[path = "waking_macos_test.rs"] mod tests; diff --git a/crates/cp-core/src/dib.rs b/crates/cp-core/src/dib.rs index 7efb3ab1..41298115 100644 --- a/crates/cp-core/src/dib.rs +++ b/crates/cp-core/src/dib.rs @@ -199,5 +199,5 @@ pub fn from_image(decoded: &image::DynamicImage) -> Option> { mod tests; #[cfg(test)] -#[path = "dib_properties.rs"] +#[path = "dib_properties_test.rs"] mod properties; diff --git a/crates/cp-core/src/formats.rs b/crates/cp-core/src/formats.rs index 2b664f21..29eb16a2 100644 --- a/crates/cp-core/src/formats.rs +++ b/crates/cp-core/src/formats.rs @@ -134,9 +134,9 @@ impl Catalog { mod tests; #[cfg(test)] -#[path = "formats_windows_needs.rs"] +#[path = "formats_windows_needs_test.rs"] mod windows_needs; #[cfg(test)] -#[path = "formats_properties.rs"] +#[path = "formats_properties_test.rs"] mod properties; diff --git a/crates/cp-core/src/item.rs b/crates/cp-core/src/item.rs index 9c9a8a79..72ce5c1f 100644 --- a/crates/cp-core/src/item.rs +++ b/crates/cp-core/src/item.rs @@ -135,5 +135,5 @@ impl Item { mod tests; #[cfg(test)] -#[path = "item_properties.rs"] +#[path = "item_properties_test.rs"] mod properties; diff --git a/crates/cp-core/src/kind.rs b/crates/cp-core/src/kind.rs index 0dcaa787..315f7e83 100644 --- a/crates/cp-core/src/kind.rs +++ b/crates/cp-core/src/kind.rs @@ -304,13 +304,13 @@ fn looks_like_code(text: &str) -> bool { mod tests; #[cfg(test)] -#[path = "kind_borders.rs"] +#[path = "kind_borders_test.rs"] mod borders; #[cfg(test)] -#[path = "kind_redundancy.rs"] +#[path = "kind_redundancy_test.rs"] mod redundancy; #[cfg(test)] -#[path = "kind_inherited_from_2x.rs"] +#[path = "kind_inherited_from_2x_test.rs"] mod inherited_from_2x; diff --git a/crates/cp-core/src/paste.rs b/crates/cp-core/src/paste.rs index a8df9660..81a0f5e7 100644 --- a/crates/cp-core/src/paste.rs +++ b/crates/cp-core/src/paste.rs @@ -113,5 +113,5 @@ impl Failure { mod tests; #[cfg(test)] -#[path = "paste_properties.rs"] +#[path = "paste_properties_test.rs"] mod properties; diff --git a/crates/cp-core/src/watch.rs b/crates/cp-core/src/watch.rs index 59af924d..50b33711 100644 --- a/crates/cp-core/src/watch.rs +++ b/crates/cp-core/src/watch.rs @@ -111,7 +111,7 @@ impl Watcher { } #[cfg(test)] -#[path = "watch_insisting.rs"] +#[path = "watch_insisting_test.rs"] mod insisting; #[cfg(test)] @@ -119,9 +119,9 @@ mod insisting; mod tests; #[cfg(test)] -#[path = "watch_windows_counter.rs"] +#[path = "watch_windows_counter_test.rs"] mod windows_counter; #[cfg(test)] -#[path = "watch_properties.rs"] +#[path = "watch_properties_test.rs"] mod properties; diff --git a/crates/cp-panel/src/engine.rs b/crates/cp-panel/src/engine.rs index 9edc57c9..048d4b8f 100644 --- a/crates/cp-panel/src/engine.rs +++ b/crates/cp-panel/src/engine.rs @@ -49,8 +49,6 @@ const SIDE: i32 = cp_core::thumbnail::MAX_SIDE as i32; const NAP: std::time::Duration = std::time::Duration::from_millis(400); const LATER: i64 = 60_000; const SWEEPS_EVERY: std::time::Duration = std::time::Duration::from_secs(3_600); -#[cfg(test)] -const A_DAY: i64 = cp_store::A_DAY; fn errands(db: &Path, stop: Arc) -> Option> { let store = match Store::open(db) { diff --git a/crates/cp-panel/src/engine_test.rs b/crates/cp-panel/src/engine_test.rs index 7acb41fb..cb9cec1a 100644 --- a/crates/cp-panel/src/engine_test.rs +++ b/crates/cp-panel/src/engine_test.rs @@ -1,3 +1,5 @@ +const A_DAY: i64 = cp_store::A_DAY; + use super::*; use cp_core::item::{Format, Payload, SYNTHETIC_IMAGE, SYNTHETIC_TEXT}; diff --git a/crates/cp-store/src/store.rs b/crates/cp-store/src/store.rs index 9959838d..5a03ad7a 100644 --- a/crates/cp-store/src/store.rs +++ b/crates/cp-store/src/store.rs @@ -1445,13 +1445,13 @@ fn search(store: &Store, query: &str) -> Vec { mod tests; #[cfg(test)] -#[path = "store_identity.rs"] +#[path = "store_identity_test.rs"] mod identity; #[cfg(test)] -#[path = "store_listing.rs"] +#[path = "store_listing_test.rs"] mod listing; #[cfg(test)] -#[path = "store_housekeeping.rs"] +#[path = "store_housekeeping_test.rs"] mod housekeeping; diff --git a/crates/cp-win/src/drop.rs b/crates/cp-win/src/drop.rs index 28bf9948..4e371943 100644 --- a/crates/cp-win/src/drop.rs +++ b/crates/cp-win/src/drop.rs @@ -46,20 +46,6 @@ pub fn drop_of>(paths: &[S]) -> Vec { out } -#[cfg(test)] -pub(crate) fn ansi_drop_of(paths: &[&str]) -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(&(HEADER as u32).to_le_bytes()); - out.extend_from_slice(&[0u8; 12]); - out.extend_from_slice(&0u32.to_le_bytes()); - for path in paths { - out.extend_from_slice(path.as_bytes()); - out.push(0); - } - out.push(0); - out -} - #[cfg(test)] #[path = "drop_test.rs"] mod tests; diff --git a/crates/cp-win/src/drop_test.rs b/crates/cp-win/src/drop_test.rs index 8bedfa1a..a2de160f 100644 --- a/crates/cp-win/src/drop_test.rs +++ b/crates/cp-win/src/drop_test.rs @@ -1,3 +1,16 @@ +fn ansi_drop_of(paths: &[&str]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&(HEADER as u32).to_le_bytes()); + out.extend_from_slice(&[0u8; 12]); + out.extend_from_slice(&0u32.to_le_bytes()); + for path in paths { + out.extend_from_slice(path.as_bytes()); + out.push(0); + } + out.push(0); + out +} + use super::*; #[test] From 19f75e69e6fba198a3fef6cafde72708218fe5f2 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 29 Sep 2026 23:20:44 -0300 Subject: [PATCH 8/9] fix(ci): the coverage exclusions lost their twins, and nothing said where a test goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `crates/cp-panel/src/app.rs` and `measure.rs` are excluded from coverage on purpose, in four `--ignore-filename-regex` arguments and in `sonar.coverage.exclusions`. The pattern names `(app|main|measure)\.rs`, which cannot match `app_test.rs`. So 58 lines that the policy says not to measure arrived at ~100% into the denominator of `--fail-under-lines 90` and `95`, loosening the gate, and Sonar would have shown `app_test.rs` fully covered in a crate whose `app.rs` is deliberately uncovered. The regex takes `(_test)?` now, and the two siblings join the Sonar list. Measured, so the size of it is not a guess: across cp-config the lcov total does not move at all — `lib.rs` 244 lines at 98.36% before, `lib.rs` 87 plus `lib_test.rs` 157 at the same 98.36% after — because those lines were already counted inside the file they came from. Only the two excluded files changed anything. `sonar.test.inclusions` now claims the siblings as tests rather than sources. They were analysed as production before too, inline, so this is not a regression being fixed but an opportunity the split opened: 14.726 lines stop being weighed as product code. And CONTRIBUTING.md said "Tests when appropriate", which left a contributor writing `#[cfg(test)] mod tests { }` inside the file — the thing this branch just undid 94 times, and which now fails CI. It carries the pattern, the naming rule and why the name matters. While there: it asked for "comments in English", which has contradicted the "the code carries no comments" rule in rules.yml for as long as both existed. --- .github/workflows/ci.yml | 12 ++++++------ CONTRIBUTING.md | 28 ++++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77a9bfcf..5b2b2143 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,8 +144,8 @@ jobs: - run: cargo llvm-cov --no-report --workspace - run: cargo llvm-cov --no-report run -p cp-mac --example mac_probe - - run: cargo llvm-cov report --lcov --output-path lcov-rust.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' - - run: cargo llvm-cov report --fail-under-lines 90 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --lcov --output-path lcov-rust.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)(_test)?\.rs|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --fail-under-lines 90 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)(_test)?\.rs|src-tauri[/\]src[/\]' - uses: actions/setup-node@v7 with: @@ -192,8 +192,8 @@ jobs: shell: pwsh run: Set-Clipboard -Value "cp-probe" - run: cargo llvm-cov --no-report run -p cp-win --example win_probe - - run: cargo llvm-cov report --lcov --output-path lcov-win.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' - - run: cargo llvm-cov report --fail-under-lines 95 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)\.rs|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --lcov --output-path lcov-win.info --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)(_test)?\.rs|src-tauri[/\]src[/\]' + - run: cargo llvm-cov report --fail-under-lines 95 --summary-only --ignore-filename-regex 'cp-panel[/\]src[/\](app|main|measure)(_test)?\.rs|src-tauri[/\]src[/\]' - name: Every path is relative to the repository, which is what Sonar reads if: always() shell: bash @@ -248,11 +248,11 @@ jobs: -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} -Dsonar.sources=crates,app/src,app/src-tauri/src -Dsonar.tests=app/src - -Dsonar.test.inclusions=app/src/**/*.test.ts,app/src/**/*.test.tsx,app/src/tests/** + -Dsonar.test.inclusions=app/src/**/*.test.ts,app/src/**/*.test.tsx,app/src/tests/**,crates/**/*_test.rs,app/src-tauri/src/**/*_test.rs -Dsonar.exclusions=crates/*/examples/**,crates/*/build.rs -Dsonar.rust.lcov.reportPaths=lcov-rust.info,lcov-win.info -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info -Dsonar.rust.clippy.enabled=false - -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/**,crates/cp-panel/src/main.rs,crates/cp-panel/src/app.rs,crates/cp-panel/src/measure.rs + -Dsonar.coverage.exclusions=app/src/main.tsx,app/src-tauri/src/**,crates/cp-panel/src/main.rs,crates/cp-panel/src/app.rs,crates/cp-panel/src/app_test.rs,crates/cp-panel/src/measure.rs,crates/cp-panel/src/measure_test.rs -Dsonar.qualitygate.wait=${{ github.event_name == 'pull_request' }} -Dsonar.qualitygate.timeout=300 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8097e826..7258f8bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,9 +52,29 @@ In many ways! Code is just one of them: 2. **Create a branch** from `main` (`git checkout -b feature/my-improvement`) 3. **Make your changes** following our style: - Clean and minimalist code - - Code, identifiers and comments in English - - Tests when appropriate -4. **Make sure** all tests pass: + - Code and identifiers in English + - No comments. CI refuses them: a name that needs a comment is the wrong name. + Only a hidden constraint earns one line + - Tests when appropriate, and **in a file of their own** — see below +4. **Where a test goes.** Never inside the file it tests. Each file keeps its + tests in a sibling, declared at the bottom of the file under test: + + ```rust + #[cfg(test)] + #[path = "watch_test.rs"] + mod tests; + ``` + + The sibling lives in the same directory, holds the body without the + `mod tests { }` wrapper, and is still a child module — `use super::*` and + access to private items work exactly as if it were inline. Its name is the + file plus `_test.rs`; when one file has several test modules, the name says + which one it is and still ends in `_test.rs`: `store_listing_test.rs`, + `kind_borders_test.rs`, `waking_windows_test.rs`. The name matters, because + it is how the line ceiling, the coverage report and Sonar tell a test from + the code it tests. A test module written inline fails CI. + +5. **Make sure** all tests pass: ```sh python3 scripts/sidecar.py --debug # the app carries the panel; build it first @@ -63,7 +83,7 @@ In many ways! Code is just one of them: cd app && npm ci && npm run lint && npm test ``` -5. **Open a Pull Request** to `main` +6. **Open a Pull Request** to `main` ### Translate From 4dbefc7266ed54b4f9b6104ef27dae7da0cba708 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Wed, 30 Sep 2026 09:12:41 -0300 Subject: [PATCH 9/9] fix(rules): the new guards had four open doors, and the Sonar change did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code review over the last round found ten holes worth closing. Three were mine from that round. **The Sonar change was inert.** `sonar.test.inclusions` only filters inside the roots that `sonar.tests` names, and that is still `app/src`, so the two Rust globs added last commit could never match a file and the 94 siblings stayed main source. They are out again rather than half-done: widening `sonar.tests` without also moving those paths out of `sonar.sources` aborts the scan with «can't be indexed twice», and with `sonar.qualitygate.wait=true` a broken scan blocks every PR. Classifying them as tests is worth doing and needs a run to verify, which is not this branch. **CONTRIBUTING.md licensed a comment CI refuses.** It said a hidden constraint earns one line; `rules.yml` fails on any `//` or `/*` in `crates` and `app/src-tauri/src`, with no exemption. And it claimed the `_test.rs` name is how the ceiling tells a test from code, which was false — the measurer keyed on the `#[path]` declaration and never looked at the name. That is true now, because the declaration must point at a `_test.rs`, and the doc says what actually holds. **Moving the tests out switched off `clippy::items_after_test_module`.** With the module inline, clippy refused code after it; behind `#[path]` it sees nothing, so the declaration could sit mid-file with two hundred lines below and everything passed. `app/src-tauri/src/backup.rs` was the standing proof, carrying 94 lines after its declaration — green on main only because a `#[derive]` on the next item suppresses the lint. Its declaration goes last now, like the other 93, and `--inline` checks it for all of them. **The cfg matcher was wrong in both directions.** `[^)]*\btest\b` matched `#[cfg(not(test))]`, `#[cfg(feature = "test")]` and `#[cfg(target_os = "test")]` — so three lines of an ordinary platform split exempted a 1552-line production file — and missed `#[cfg(all(not(miri), test))]`, because the character class cannot cross a nested paren. One matcher now serves both the ceiling and the ban: it neutralises strings, drops `not(test)`, and then looks for `test` as a word. **The exemption never required the suffix.** `#[path = "giant.rs"]` on a production file hid it from both ceilings, bypassing `.github/oversized.txt`, which is the opt-out that demands a count, a path and a written reason. The declaration must name a `*_test.rs`. **And the ban had four open doors:** an attribute between the `cfg` and the `mod`, both on one line, `mod tests{` without the space, and `mod Tests {` with a capital. It is no longer a grep — `scripts/oversized.py --inline` shares the matcher, so the ban and the exemption cannot disagree about what a test gate is — and it also refuses a `*_test.rs` that nothing declares, which was the missing twin: lose a declaration and the tests stop running, rustfmt stops reaching the file, and the only error that appears blames the test file for being 1615 lines. Seventeen cases, each failing on purpose: four shapes that must not exempt and one nested-paren shape that must, six spellings of an inline module plus one `#[cfg(not(test))] mod` that is legitimate, code after a declaration, and an undeclared sibling. Two more, smaller. The example-name check could not see cargo's directory form, `examples//main.rs`, so two crates could still collide through it; it walks both forms now, in every crate, and both new steps carry `set -euo pipefail` — without it, a renamed directory made them print success and exit 0 on a `grep` that found nothing to read. And `examples/` and `build.rs` are out of the ban's reach, because there the prescribed sibling becomes its own target and fails to build for want of a `main`: a contributor had no legal option there. **One finding is measured and deliberately not acted on.** The coverage exclusion still names `(app|main|measure)(_test)?\.rs` rather than the general `_test\.rs$`, which is the honest rule. Measured on this tree: excluding every sibling takes line coverage from 92.80% to 83.21%, and the gates are `--fail-under-lines 90` and `95`. The general rule needs the thresholds recalibrated, which is a decision, not a cleanup. The two twins that mattered stay excluded, and that costs 0.02 points. The `cross` job gains `--all-targets` for cp-mac-sys, which compiles nine of the fourteen moved macOS test files on a non-macOS host. cp-mac cannot join it: its dev-dependencies pull blake3 and libsqlite3-sys, whose build scripts want a C toolchain for darwin. Verified both ways. 853 tests, clippy clean with `-D warnings`, rustfmt conforming, both ceilings green, markdownlint clean. --- .github/workflows/ci.yml | 3 +- .github/workflows/rules.yml | 24 ++++------ CONTRIBUTING.md | 14 +++--- app/src-tauri/src/backup.rs | 8 ++-- scripts/oversized.py | 87 ++++++++++++++++++++++++++++++++----- 5 files changed, 101 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b2b2143..50ecea8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,6 +112,7 @@ jobs: shared-key: cross save-if: ${{ github.ref == 'refs/heads/main' }} - run: cargo check --target aarch64-apple-darwin -p cp-mac -p cp-mac-sys + - run: cargo check --target aarch64-apple-darwin -p cp-mac-sys --all-targets deny: name: advisories + licences @@ -248,7 +249,7 @@ jobs: -Dsonar.organization=${{ vars.SONAR_ORGANIZATION }} -Dsonar.sources=crates,app/src,app/src-tauri/src -Dsonar.tests=app/src - -Dsonar.test.inclusions=app/src/**/*.test.ts,app/src/**/*.test.tsx,app/src/tests/**,crates/**/*_test.rs,app/src-tauri/src/**/*_test.rs + -Dsonar.test.inclusions=app/src/**/*.test.ts,app/src/**/*.test.tsx,app/src/tests/** -Dsonar.exclusions=crates/*/examples/**,crates/*/build.rs -Dsonar.rust.lcov.reportPaths=lcov-rust.info,lcov-win.info -Dsonar.javascript.lcov.reportPaths=app/coverage/lcov.info diff --git a/.github/workflows/rules.yml b/.github/workflows/rules.yml index d85435d9..019b517a 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -121,24 +121,18 @@ jobs: fi - name: The tests live beside the file, not inside it - run: | - found=$(grep -rnA1 -E '^[[:space:]]*#\[cfg\([^)]*\btest\b' \ - crates app/src-tauri/src --include='*.rs' \ - | grep -E '^[^:]+[:-][0-9]+[:-][[:space:]]*mod [a-z_0-9]+ \{' || true) - if [ -n "$found" ]; then - echo "$found" - echo "::error::a test module goes in a file of its own beside the one it tests, \ - declared as «#[cfg(test)] #[path = \"_test.rs\"] mod tests;». Inside the \ - file it buries the code and the ceiling stops measuring what a reader has to \ - scroll past." - exit 1 - fi - echo "no test module is buried in a file of code" + run: python3 scripts/oversized.py --inline - name: No two crates name an example the same run: | - said=$(find crates -mindepth 3 -maxdepth 3 -path '*/examples/*.rs' \ - -exec basename {} .rs \; | sort) + set -euo pipefail + dirs=$(find . -type d -name examples -not -path './target/*' \ + -not -path '*/node_modules/*' | sort) + said=$(for one in $dirs; do + find "$one" -mindepth 1 -maxdepth 1 -name '*.rs' -exec basename {} .rs \; + find "$one" -mindepth 2 -maxdepth 2 -name 'main.rs' -printf '%h\n' \ + | xargs -r -n1 basename + done | sort) twice=$(printf '%s\n' "$said" | uniq -d) if [ -n "$twice" ]; then echo "::error::two crates name an example the same, and cargo links every example to \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7258f8bc..edb67761 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,8 +53,8 @@ In many ways! Code is just one of them: 3. **Make your changes** following our style: - Clean and minimalist code - Code and identifiers in English - - No comments. CI refuses them: a name that needs a comment is the wrong name. - Only a hidden constraint earns one line + - No comments, with no exception. CI refuses any `//` or `/*` in `crates` and + `app/src-tauri/src`: a name that needs a comment is the wrong name - Tests when appropriate, and **in a file of their own** — see below 4. **Where a test goes.** Never inside the file it tests. Each file keeps its tests in a sibling, declared at the bottom of the file under test: @@ -70,9 +70,13 @@ In many ways! Code is just one of them: access to private items work exactly as if it were inline. Its name is the file plus `_test.rs`; when one file has several test modules, the name says which one it is and still ends in `_test.rs`: `store_listing_test.rs`, - `kind_borders_test.rs`, `waking_windows_test.rs`. The name matters, because - it is how the line ceiling, the coverage report and Sonar tell a test from - the code it tests. A test module written inline fails CI. + `kind_borders_test.rs`, `waking_windows_test.rs`. The suffix is not decorative: + the ceiling refuses a declaration pointing at a name without it, so a sibling + named anything else is measured as production code and fails at 1500 lines. + The declaration goes **last** in the file, after the code — clippy asked for + that while the module was inline and cannot see it any more, so + `scripts/oversized.py --inline` does. A test module written inside the file + fails CI, as does a `*_test.rs` that nothing declares. 5. **Make sure** all tests pass: diff --git a/app/src-tauri/src/backup.rs b/app/src-tauri/src/backup.rs index 50c02900..385e8284 100644 --- a/app/src-tauri/src/backup.rs +++ b/app/src-tauri/src/backup.rs @@ -104,10 +104,6 @@ fn bring(from: &std::path::Path, at: i64) -> Result { cp_store::backup::bring(from, &store, at).map_err(|why| why.to_string()) } -#[cfg(test)] -#[path = "backup_test.rs"] -mod tests; - #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Crossed { @@ -207,3 +203,7 @@ pub fn drop_former() -> Result { bytes: swept.bytes, }) } + +#[cfg(test)] +#[path = "backup_test.rs"] +mod tests; diff --git a/scripts/oversized.py b/scripts/oversized.py index 1667fd51..339affa0 100644 --- a/scripts/oversized.py +++ b/scripts/oversized.py @@ -12,9 +12,13 @@ QUOTED = re.compile(r'"(?:[^"\\]|\\.)*"') CHARRED = re.compile(r"'(?:[^'\\]|\\.)'") NOTED = re.compile(r"//.*$") -GATED = re.compile(r"^#\[cfg\([^)]*\btest\b") -DECLARED = re.compile(r'^#\[path = "(?P[A-Za-z0-9_]+\.rs)"\]$') +A_CFG = re.compile(r"^#\[cfg\(") +NOT_TESTS = re.compile(r"not\s*\(\s*test\s*\)") +TESTED = re.compile(r"\btest\b") +DECLARED = re.compile(r'^#\[path = "(?P[A-Za-z0-9_]+_test\.rs)"\]$') A_CHILD = re.compile(r"^mod \w+;$") +A_BLOCK = re.compile(r"^mod \w+\s*\{") +AN_ATTRIBUTE = re.compile(r"^#\[") HEADED = re.compile( r"(?P\s*)(pub(\([^)]*\))? )?(default )?(const )?(async )?(unsafe )?" r'(extern "[A-Za-z-]+" )?fn (?P\w+)' @@ -86,19 +90,33 @@ def candidates() -> list[pathlib.Path]: return every +def gates_tests(line: str) -> bool: + if not A_CFG.match(line): + return False + bare = NOT_TESTS.sub("", QUOTED.sub('""', line)) + return TESTED.search(bare) is not None + + +def declares_in(at: pathlib.Path) -> list[tuple[int, str]]: + lines = [one.strip() for one in lines_of(at)] + found = [] + for turn in range(len(lines) - 2): + if not gates_tests(lines[turn]): + continue + named = DECLARED.match(lines[turn + 1]) + if not named or not A_CHILD.match(lines[turn + 2]): + continue + found.append((turn, named.group("file"))) + return found + + def only_tests(among: list[pathlib.Path]) -> set[str]: said = set() for at in among: if at.suffix != ".rs": continue - lines = [one.strip() for one in lines_of(at)] - for turn in range(len(lines) - 2): - if not GATED.match(lines[turn]): - continue - named = DECLARED.match(lines[turn + 1]) - if not named or not A_CHILD.match(lines[turn + 2]): - continue - beside = at.with_name(named.group("file")) + for _, name in declares_in(at): + beside = at.with_name(name) if beside != at: said.add(beside.as_posix()) return said @@ -140,7 +158,56 @@ def noted() -> dict[str, int] | None: return kept +def hosts() -> list[pathlib.Path]: + every = [] + for where in WHERE: + base = pathlib.Path(where) + if not base.is_dir(): + continue + for at in base.rglob("*.rs"): + said = at.as_posix() + if "/examples/" in said or at.name == "build.rs" or "/tests/" in said: + continue + if where == "crates" and "/src/" not in said: + continue + every.append(at) + return every + + +def inline() -> int: + status = 0 + every = hosts() + for at in every: + lines = [one.strip() for one in lines_of(at)] + for turn, line in enumerate(lines): + if not gates_tests(line): + continue + ahead = turn + 1 + while ahead < len(lines) and (AN_ATTRIBUTE.match(lines[ahead]) and not DECLARED.match(lines[ahead])): + ahead += 1 + if ahead < len(lines) and A_BLOCK.match(lines[ahead]): + print(f"::error file={at.as_posix()},line={ahead + 1}::a test module goes in a file of its own beside this one, declared as «#[cfg(test)] #[path = \"{at.stem}_test.rs\"] mod tests;». Inside the file it buries the code and the ceiling stops measuring what a reader has to scroll past") + status = 1 + said = declares_in(at) + if said: + after = said[-1][0] + 3 + spare = [one for one in lines[after:] if one] + if spare: + print(f"::error file={at.as_posix()},line={after + 1}::{len(spare)} lines of code follow the test declaration; it goes last, the way clippy's items_after_test_module asked when the module was still inline") + status = 1 + declared = {one for at in every for one in only_tests([at])} + for at in every: + if at.name.endswith("_test.rs") and at.as_posix() not in declared: + print(f"::error file={at.as_posix()}::nothing declares this file, so its tests never run, rustfmt never reaches it and the ceiling measures it as production") + status = 1 + if status == 0: + print(f"{len(declared)} test files, each declared last in the file it tests") + return status + + def main() -> int: + if "--inline" in sys.argv: + return inline() found = measured() if "--print" in sys.argv: for what, held in sorted(found.items(), key=lambda one: -one[1]):