Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions docs/phase-3-cross-platform-verification.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 43 additions & 29 deletions src/document_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
48 changes: 36 additions & 12 deletions src/document_store/tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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}"),
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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();
Expand Down
Loading