diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c3a973..d181662 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,3 +85,10 @@ jobs: - name: Test all targets on macOS and Windows if: runner.os != 'Linux' run: cargo test --locked --all-targets -- --test-threads=1 + + # The Phase 3 gate (issue #22) exercises the full reference workload of + # approximately 1,000 Files and 50 MiB. The test is ignored by default + # because its normalization pass is too slow for the everyday suite; + # this step runs it explicitly on every platform. + - name: Reference workload gate + run: cargo test --locked --lib -- --ignored catalog_completes_the_reference_volume_workload --test-threads=1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9719d..44ed2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added Paper and opt-in Ink writing themes with active-line highlighting and themed scrollbars. - Added Linux, macOS, and Windows CI builds plus launch and graceful-shutdown smoke checks. - Added Phase 2 cross-platform persistence verification checklist and explicit platform limitations. +- Added Phase 3 cross-platform selector verification checklist, reference workload, and explicit platform limitations. - Aligned cursor and selection decorations with Floem glyph metrics. - Autosave now stops after a conflict is detected until the writer resolves it, and conflict resolutions persist the newest pending edit. - Config-write failures during background autosaves now surface as session feedback instead of being dropped on the worker thread. diff --git a/docs/phase-3-cross-platform-verification.md b/docs/phase-3-cross-platform-verification.md new file mode 100644 index 0000000..5687811 --- /dev/null +++ b/docs/phase-3-cross-platform-verification.md @@ -0,0 +1,94 @@ +# Phase 3 cross-platform selector verification + +Issue #22 closes Phase 3's selector gate. This document records automated coverage, the manual verification record, the reference workload, and known limits without treating unavailable platform testing as verified. + +## Automated gate + +`.github/workflows/ci.yml` runs on `ubuntu-latest`, `macos-latest`, and `windows-latest`. Each job uses the locked dependency graph and runs: + +- `cargo fmt --all -- --check`; +- `cargo build --locked --all-targets`; +- `cargo clippy --locked --all-targets --all-features -- -D warnings`; +- `cargo test --locked --all-targets -- --test-threads=1`. + +The test command includes `tests/launch_smoke.rs`. Linux installs the X11, EGL, GTK, Wayland, and `libxkbcommon-x11` runtime libraries the GUI stack needs and runs the suite through Xvfb; macOS and Windows use their hosted desktop sessions. + +## Native platform coverage + +The selector behaviors that differ by platform are exercised by native tests rather than UI timing: + +| Behavior | Test | Location | +| --- | --- | --- | +| Platform default Document directory | `resolves_the_platform_default_document_directory` | `src/document_store/tests.rs` | +| Platform default config path | `resolves_the_platform_default_config_path` | `src/session_config.rs` | +| `.txt` extension case (`visible.TXT` discovered) | `catalog_includes_direct_child_plain_text_files_and_missing_directories_are_empty` | `src/document_store/tests.rs` | +| Modification-time ordering and formatting | `catalog_orders_by_time_then_title_and_disambiguates_duplicate_titles`, `modification_time_format_uses_expected_shape` | `src/document_store/tests.rs` | +| Shortcut modifier (Cmd on macOS, Ctrl elsewhere) | `selector_shortcut_uses_control_on_non_macos`, `selector_shortcut_uses_command_on_macos`, plus repeat/shift/alt rejection | `src/editor.rs` | +| Filesystem behavior (symlinks, hidden Files, invalid UTF-8, unreadable entries, directory errors) | `catalog_excludes_symbolic_links`, hidden-File assertions in the catalog test, `catalog_skips_invalid_utf8_and_keeps_valid_entries`, `catalog_omits_unreadable_files_with_warning`, `catalog_enumeration_failure_is_distinct_from_empty_catalog`, `catalog_aggregates_warnings_and_keeps_valid_cards_without_touching_sources` | `src/document_store/tests.rs` | + +Invalid-UTF-8 File *names* can only be constructed on filesystems that accept arbitrary name bytes; APFS (macOS) rejects them, so `catalog_skips_files_with_unusable_non_utf8_names_with_warning` is gated `#[cfg(all(unix, not(target_os = "macos")))]` and macOS keeps that cell explicitly unverified. + +## Launch smoke coverage + +`tests/launch_smoke.rs` starts the real application and asserts it stays alive, then runs `--smoke-test`, which walks the selector wiring and asserts a clean exit within five seconds: + +- selector open via the shared shortcut hook, focused on search; +- query input through the editor path and the domain-backed `set_query`/`publish_matches` filter; +- grid targeting via `focus_grid` and `activate_selected`, then File-open wiring through `open_file`; +- Escape routed to the same `close_selector` closure, leaving the editor untouched and the session clean; +- New Draft wiring from inside the selector (the smoke asserts the success branch; flush-or-block failure semantics stay domain-tested); +- fresh-load failure keeping the selector open, surfacing the error, refreshing the catalog, and Retry restoring a clean slate; +- focus restoration through the `restore_editor_focus` calls on the close and File-open paths the smoke walks (the widget-level focus change itself is an interactive check); +- clean shutdown through the application's quit path. + +The smoke test is shell wiring, not OS-level keyboard automation; widget-level focus and visual layout remain interactive checks. + +## Reference workload + +`catalog_completes_the_reference_count_without_size_caps` in `src/document_store/tests.rs` runs in the everyday suite: 1,000 direct `.txt` Files are cataloged with zero warnings, literal search matches all 1,000 cards for a shared phrase and exactly one for a unique title, and no per-File or aggregate size cap is applied. + +The full volume target — `catalog_completes_the_reference_volume_workload`, roughly 1,000 Files totaling 50 MiB — is `#[ignore]`d in the suite because its NFKC normalization pass is too slow for the everyday test run, and CI runs it explicitly in the dedicated `Reference workload gate` step on every platform. This exercises the reference workload without claiming guarantees for unbounded File sizes: Lettera has no explicit per-File or aggregate size cap, and the workload is the stated reference. + +## Manual verification record + +Run each check on Linux, macOS, and Windows. Mark a cell only after running that platform directly. `☐` means **not verified**, not pass. + +### Selector layout and interaction + +| Check | Expected result | Linux | macOS | Windows | +| --- | --- | --- | --- | --- | +| Modal geometry | `Ctrl/Cmd+P` opens a centered elevated modal over the inert Writing surface; the Writing surface stays visible behind it. | ☐ | ☐ | ☐ | +| Adaptive columns | Resizing the window changes the grid from one to several equal columns; cards keep equal heights. | ☐ | ☐ | ☐ | +| Internal scroll | Only the modal grid scrolls; the background Writing surface stays stable. | ☐ | ☐ | ☐ | +| Focus trap | Pointer input over the overlay closes the selector; typing edits the search field, not the Document. | ☐ | ☐ | ☐ | +| Keyboard search | Initial focus is on search; typing filters immediately; left/right arrows edit the field normally. | ☐ | ☐ | ☐ | +| Grid arrows | Down arrow moves from search into the grid; grid arrows follow visible rows and columns without horizontal wrap; printable input returns to search. | ☐ | ☐ | ☐ | +| Pointer behavior | Clicking a card selects it; activating opens it; clicking outside closes. | ☐ | ☐ | ☐ | +| Active/selected distinction | The active File and the keyboard-selected File show different states, distinguishable without color alone. | ☐ | ☐ | ☐ | +| Local dates | Cards show local File modification time as `YYYY-MM-DD HH:MM` and it stays stable while the selector is open. | ☐ | ☐ | ☐ | +| English copy | `Untitled`, empty, warning, and error states use English copy. | ☐ | ☐ | ☐ | + +### Filesystem, persistence, and edge behavior + +| Check | Expected result | Linux | macOS | Windows | +| --- | --- | --- | --- | --- | +| IME | IME composition works in both the editor and the selector search field. | ☐ | ☐ | ☐ | +| External and hidden `.txt` Files | Files created outside Lettera and hidden `.txt` Files are discovered as cards; non-`.txt`, nested, and symlinked entries are not. | ☐ | ☐ | ☐ | +| Invalid UTF-8 warnings | An invalid-UTF-8 File leaves healthy cards available, counts as an aggregate warning, and is never rewritten. | ☐ | ☐ | ☐ | +| Directory errors and Retry | An unreadable Document directory reports a distinct error; Retry rescans after fixing permissions or I/O. | ☐ | ☐ | ☐ | +| Save errors | A failed flush keeps the selector open with the target pending and a working Retry. | ☐ | ☐ | ☐ | +| Conflicts | An unresolved external conflict blocks opening a selected File and surfaces the existing resolution choices. | ☐ | ☐ | ☐ | +| Fresh-load races | A File deleted after the snapshot reports an error, keeps the selector open, and stops being selectable after the refresh. | ☐ | ☐ | ☐ | +| New Draft | `Ctrl/Cmd+N` from inside the selector flushes, closes the selector, and leaves a clean empty Draft. | ☐ | ☐ | ☐ | +| Focus restoration | Closing the selector returns caret editing to the active Document with its content, caret, and undo history intact. | ☐ | ☐ | ☐ | + +## Scope guard + +Phase 3 adds no deletion, pinning, folders, recursive navigation, persistent index or file watchers, preferences, permanent status chrome, i18n, or release packaging. Navigation is a temporary modal; closing it leaves the Writing surface as the only permanent surface. + +## Verification limits + +- Linux automated build, tests, and launch smoke can be run in this environment; hosted macOS and Windows results are read from the corresponding GitHub Actions run. +- CI and smoke tests do not prove OS-level keyboard, IME, focus, pointer, visual, or shutdown behavior; those remain manual cells above. +- The reference workload is exercised at approximately 1,000 Files and 50 MiB; no guarantee is claimed for unbounded File sizes. +- Every manual cell stays unverified until it is executed directly on that platform. diff --git a/src/document_store.rs b/src/document_store.rs index 38fdf80..76aa5b1 100644 --- a/src/document_store.rs +++ b/src/document_store.rs @@ -129,38 +129,52 @@ impl CatalogSnapshot { } fn sort_and_disambiguate(&mut self) { - self.entries.sort_by(|left, right| { - right - .modified - .cmp(&left.modified) - .then_with(|| { - case_insensitive_key(&left.title).cmp(&case_insensitive_key(&right.title)) - }) - .then_with(|| { - case_insensitive_key(left.file.name().unwrap_or_default()) - .cmp(&case_insensitive_key(right.file.name().unwrap_or_default())) - }) - .then_with(|| { - left.file - .name() - .unwrap_or_default() - .cmp(right.file.name().unwrap_or_default()) - }) - }); - - for entry in &mut self.entries { + // Compute the sort keys once so the reference workload stays linear; + // recomputing lowercased titles inside the sort comparator and a + // nested duplicate scan made 1,000 Files quadratic. The disambiguation + // pass below recomputes title keys but stays linear via a HashMap. + let mut keyed: Vec<(String, String, CatalogEntry)> = std::mem::take(&mut self.entries) + .into_iter() + .map(|entry| { + ( + case_insensitive_key(&entry.title), + case_insensitive_key(entry.file.name().unwrap_or_default()), + entry, + ) + }) + .collect(); + keyed.sort_by( + |(left_title, left_name, left), (right_title, right_name, right)| { + right + .modified + .cmp(&left.modified) + .then_with(|| left_title.cmp(right_title)) + .then_with(|| left_name.cmp(right_name)) + .then_with(|| { + left.file + .name() + .unwrap_or_default() + .cmp(right.file.name().unwrap_or_default()) + }) + }, + ); + self.entries = keyed.into_iter().map(|(_, _, entry)| entry).collect(); + + for entry in self.entries.iter_mut() { entry.show_filename = false; } - for index in 0..self.entries.len() { - let title = case_insensitive_key(&self.entries[index].title); - if self - .entries - .iter() - .filter(|entry| case_insensitive_key(&entry.title) == title) - .count() - > 1 + let mut counts = std::collections::HashMap::new(); + for entry in &self.entries { + *counts + .entry(case_insensitive_key(&entry.title)) + .or_insert(0usize) += 1; + } + for entry in self.entries.iter_mut() { + if counts + .get(&case_insensitive_key(&entry.title)) + .is_some_and(|&count| count > 1) { - self.entries[index].show_filename = true; + entry.show_filename = true; } } } diff --git a/src/document_store/tests.rs b/src/document_store/tests.rs index bc84405..4088490 100644 --- a/src/document_store/tests.rs +++ b/src/document_store/tests.rs @@ -1,5 +1,6 @@ use super::{ - format_modified_time, ConflictKind, Document, DocumentStore, FileFingerprint, PersistenceError, + format_modified_time, CatalogSnapshot, ConflictKind, Document, DocumentStore, FileFingerprint, + PersistenceError, }; use crate::document_store::atomic::atomic_write_with; use crate::document_store::naming::slugify; @@ -1152,16 +1153,15 @@ fn catalog_rescans_the_directory_on_every_call() { assert_eq!(store.scan_catalog().unwrap().warning_count(), 0); } -#[test] -fn catalog_completes_the_reference_workload_without_size_caps() { +/// Builds the reference workload directory: `count` Files with the given +/// body repeat factor, then catalogs it. Shared by the everyday count test +/// and the ignored volume gate test (issue #22) so both exercise the same +/// scan, warning, and search assertions at different scales. +fn scan_reference_workload(count: usize, body_repeat: usize) -> CatalogSnapshot { let temp = TemporaryDirectory::new(); - for index in 0..1000 { + for index in 0..count { let title = format!("Document {index:04}"); - // Roughly 50 KiB per File so the aggregate content exercises the - // full 50 MiB reference workload from the cross-platform gate - // (issue #22) in every CI run. - let body = "body ".repeat(1_024); - let _ = std::time::Instant::now(); + let body = "body ".repeat(body_repeat); fs::write( temp.join(format!("{title}.txt")), format!("{title}\n{body}"), @@ -1171,7 +1171,7 @@ fn catalog_completes_the_reference_workload_without_size_caps() { let snapshot = DocumentStore::at(&*temp).scan_catalog().unwrap(); - assert_eq!(snapshot.entries().len(), 1000); + assert_eq!(snapshot.entries().len(), count); assert_eq!(snapshot.warning_count(), 0); // No per-File or aggregate size cap applies; every File is cataloged. assert!(snapshot @@ -1181,9 +1181,18 @@ fn catalog_completes_the_reference_workload_without_size_caps() { assert!(snapshot .entries() .iter() - .any(|entry| entry.title() == "Document 0999")); + .any(|entry| entry.title() == format!("Document {:04}", count - 1))); + snapshot +} - // Search stays responsive at the reference volume: a phrase shared by +#[test] +fn catalog_completes_the_reference_count_without_size_caps() { + // Roughly 1 KiB per File keeps the count dimension of the reference + // workload in the everyday suite; the full 50 MiB volume runs as an + // ignored gate test exercised explicitly in CI (issue #22). + let snapshot = scan_reference_workload(1000, 200); + + // Search stays responsive at the reference count: a phrase shared by // every File matches all 1000 cards, and a unique title matches exactly // one, so no heuristic ranks or reorders the results. assert_eq!(snapshot.matching_indices("body").len(), 1000); @@ -1192,6 +1201,21 @@ fn catalog_completes_the_reference_workload_without_size_caps() { assert_eq!(snapshot.entries()[unique[0]].title(), "Document 0999"); } +/// Exercises the full reference volume from the cross-platform gate +/// (issue #22): 1,000 Files totaling roughly 50 MiB are cataloged with zero +/// warnings and searched. Ignored by default because the NFKC normalization +/// pass is too slow for the everyday suite; CI runs it explicitly. +#[ignore] +#[test] +fn catalog_completes_the_reference_volume_workload() { + // Roughly 50 KiB per File so the aggregate content reaches the + // approximately 50 MiB reference volume. + let snapshot = scan_reference_workload(1000, 10_240); + + // Search at the reference volume matches every card for a shared phrase. + assert_eq!(snapshot.matching_indices("body").len(), 1000); +} + #[test] fn modification_time_format_uses_expected_shape() { let temp = TemporaryDirectory::new();