diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b93f41f..50ecea8d 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 @@ -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 @@ -143,9 +144,9 @@ 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 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 --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)(_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: @@ -191,9 +192,9 @@ 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 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 --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)(_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 @@ -253,6 +254,6 @@ jobs: -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/.github/workflows/rules.yml b/.github/workflows/rules.yml index 61553ba9..019b517a 100644 --- a/.github/workflows/rules.yml +++ b/.github/workflows/rules.yml @@ -120,6 +120,28 @@ jobs: echo "::error::no block comments either"; exit 1 fi + - name: The tests live beside the file, not inside it + run: python3 scripts/oversized.py --inline + + - name: No two crates name an example the same + run: | + 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 \ + 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/CONTRIBUTING.md b/CONTRIBUTING.md index 8097e826..edb67761 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,9 +52,33 @@ 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, 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: + + ```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 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: ```sh python3 scripts/sidecar.py --debug # the app carries the panel; build it first @@ -63,7 +87,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 diff --git a/app/src-tauri/src/backup.rs b/app/src-tauri/src/backup.rs index 99ed405a..385e8284 100644 --- a/app/src-tauri/src/backup.rs +++ b/app/src-tauri/src/backup.rs @@ -104,24 +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)] -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") - ); - } -} - #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Crossed { @@ -221,3 +203,7 @@ pub fn drop_former() -> Result { bytes: swept.bytes, }) } + +#[cfg(test)] +#[path = "backup_test.rs"] +mod tests; 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..78ddad94 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_windows_test.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_macos_test.rs"] +mod tests; diff --git a/app/src-tauri/src/waking_macos_test.rs b/app/src-tauri/src/waking_macos_test.rs new file mode 100644 index 00000000..a5582e9d --- /dev/null +++ b/app/src-tauri/src/waking_macos_test.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_windows_test.rs b/app/src-tauri/src/waking_windows_test.rs new file mode 100644 index 00000000..f41c649c --- /dev/null +++ b/app/src-tauri/src/waking_windows_test.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-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..41298115 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_test.rs"] +mod properties; diff --git a/crates/cp-core/src/dib_properties_test.rs b/crates/cp-core/src/dib_properties_test.rs new file mode 100644 index 00000000..d1dd2a8e --- /dev/null +++ b/crates/cp-core/src/dib_properties_test.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..29eb16a2 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_test.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_test.rs"] +mod properties; diff --git a/crates/cp-core/src/formats_properties_test.rs b/crates/cp-core/src/formats_properties_test.rs new file mode 100644 index 00000000..b9d73310 --- /dev/null +++ b/crates/cp-core/src/formats_properties_test.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_test.rs b/crates/cp-core/src/formats_windows_needs_test.rs new file mode 100644 index 00000000..45456045 --- /dev/null +++ b/crates/cp-core/src/formats_windows_needs_test.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..72ce5c1f 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_test.rs"] +mod properties; diff --git a/crates/cp-core/src/item_properties_test.rs b/crates/cp-core/src/item_properties_test.rs new file mode 100644 index 00000000..c2aaa6c0 --- /dev/null +++ b/crates/cp-core/src/item_properties_test.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..315f7e83 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_test.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_test.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_test.rs"] +mod inherited_from_2x; diff --git a/crates/cp-core/src/kind_borders_test.rs b/crates/cp-core/src/kind_borders_test.rs new file mode 100644 index 00000000..db42988e --- /dev/null +++ b/crates/cp-core/src/kind_borders_test.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_test.rs b/crates/cp-core/src/kind_inherited_from_2x_test.rs new file mode 100644 index 00000000..54a6b7fb --- /dev/null +++ b/crates/cp-core/src/kind_inherited_from_2x_test.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_test.rs b/crates/cp-core/src/kind_redundancy_test.rs new file mode 100644 index 00000000..61719955 --- /dev/null +++ b/crates/cp-core/src/kind_redundancy_test.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..81a0f5e7 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_test.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..4e371943 100644 --- a/crates/cp-win/src/drop.rs +++ b/crates/cp-win/src/drop.rs @@ -47,63 +47,5 @@ pub fn drop_of>(paths: &[S]) -> Vec { } #[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)] -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..a2de160f --- /dev/null +++ b/crates/cp-win/src/drop_test.rs @@ -0,0 +1,57 @@ +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] +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" + ); +} diff --git a/scripts/oversized.py b/scripts/oversized.py index 9aaabe5b..339affa0 100644 --- a/scripts/oversized.py +++ b/scripts/oversized.py @@ -12,7 +12,13 @@ QUOTED = re.compile(r'"(?:[^"\\]|\\.)*"') CHARRED = re.compile(r"'(?:[^'\\]|\\.)'") NOTED = re.compile(r"//.*$") -ONLY_IN_TESTS = re.compile(r"#\[cfg\((all\()?\s*test\b") +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+)' @@ -23,61 +29,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) @@ -102,7 +70,7 @@ def held_of(at: pathlib.Path) -> dict[str, int]: return found -def reaching() -> list[pathlib.Path]: +def candidates() -> list[pathlib.Path]: every = [] for where in WHERE: base = pathlib.Path(where) @@ -122,6 +90,44 @@ def reaching() -> 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 + for _, name in declares_in(at): + beside = at.with_name(name) + 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(): @@ -152,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]):