cargo test58 tests, all below the UI boundary — no window is opened, no egui context is built. They run in well under a second.
The Note model and the time helpers.
| Test | Locks |
|---|---|
new_stamps_both_timestamps_and_starts_linkless |
Construction sets id/title, empty body, empty links, and equal created_at/updated_at |
touch_moves_updated_at_forward |
touch() advances updated_at |
fmt_time_buckets |
The 59 s / 60 s / 3599 s / 3600 s / 86399 s / 86400 s boundaries of the relative-time formatter |
fmt_time_never_panics_on_future_or_zero |
A future timestamp clamps to "0s ago"; epoch does not overflow |
Pure link-graph operations over &[Note] / &mut [Note] — no
ChainNotes, no filesystem.
| Test | Locks |
|---|---|
outgoing_and_backlinks_follow_the_edges |
Outgoing is per-note in link order; backlinks are derived across all notes; a missing id yields empty |
toggle_adds_then_removes_and_refuses_bad_endpoints |
Toggle is idempotent-inverse; self-links and missing endpoints are refused |
strip_records_positions_and_restore_puts_them_back |
Stripping records (source, position); restoring re-inserts at that exact position |
restore_ignores_a_missing_source |
Restoring an edge whose source note is gone is a no-op, not a panic |
normalize_links_drops_dangling_and_self_edges |
Load-time invariant: links to nonexistent ids and self-links are removed |
The one-deep undo slot.
| Test | Locks |
|---|---|
starts_empty |
No slot, take() returns None |
record_then_take_returns_the_slot_once |
A recorded delete is retrievable exactly once |
a_second_record_replaces_the_first |
One-deep: a new delete replaces the previous slot |
The ChainNotes record: everything that reaches disk except the file
itself. Delegates link work to graph and undo to history.
| Test | Locks |
|---|---|
add_allocates_sequential_ids_and_selects |
Ids increment, the new note is selected, next_id advances, the status sentence is set |
delete_strips_outgoing_and_incoming_edges |
Deleting a note removes every edge pointing at it, in both directions |
delete_of_selected_reselects_first |
Deleting the open note selects the first remaining one |
delete_missing_id_is_a_noop |
A bad id changes nothing and creates no undo slot |
undo_restores_note_and_its_incoming_edges |
Undo brings back the note and the backlinks, and selects it |
undo_restores_edges_at_their_original_positions |
An edge that was second in a list comes back second, not appended |
undo_without_a_slot_reports_nothing |
Undo with nothing to undo returns None and says so |
second_delete_replaces_the_undo_slot |
The slot is one-deep: only the most recent delete is restorable |
toggle_link_adds_then_removes_and_stamps |
Linking is a toggle; each change stamps the source note |
toggle_link_refuses_self_and_missing_endpoints |
Self-links and links to nonexistent ids are rejected |
search_matches_title_and_body_case_insensitively |
Search is case-insensitive over title and body; empty query shows all; no match returns empty |
to_record_round_trips_through_from_record |
The serde-bound snapshot round-trips through from_record |
edit_note_applies_staged_text_and_stamps_once |
Staged title/body text is applied through the state boundary, timestamps the note, and avoids a second no-op edit |
The filesystem seam, exercised against scratch directories under the system temp dir.
| Test | Locks |
|---|---|
missing_file_is_none_not_an_error |
A first run is Ok(None), not an error |
round_trip_preserves_notes_and_selection |
Save then load returns the same notes, selection, next_id, and version |
corrupt_file_is_reported_not_panicked |
Unparseable JSON yields StoreError::Corrupt, not a panic |
empty_file_is_none |
A zero-byte file is a first run |
over_cap_is_too_large |
A file past 4 MiB is refused before reading |
failed_write_leaves_previous_file_intact |
A failed save does not clobber the last good file |
every_error_variant_has_a_distinct_sentence |
Each StoreError variant has its own wording, and a write failure never claims the file could not be read |
normalize_rebuilds_id_counter_and_drops_dangling_links |
Hand-edited input: next_id is raised past the max id, dangling and self links are dropped, a bogus selected is fixed |
merge_prefers_durable_unless_boot_is_strictly_newer |
The merge tiebreak at equal, older, and newer timestamps |
save_then_load_with_selected_none |
selected: null survives the round-trip |
default_location_resolves_under_the_user_data_dir |
The default path ends in chainnotes/workspace.json |
boot_wiring_restores_a_previously_saved_record |
The boot path, not just the merge rule: a saved record survives the same load+merge sequence ChainNotesApp::new performs |
The AutoSaver controller: debounce, flush, and the save-mode labels,
against a real scratch WorkspaceStore.
| Test | Locks |
|---|---|
labels_are_distinct_per_mode |
Every SaveMode has its own status-bar sentence (I2.3) |
touch_marks_dirty_and_restarts_the_clock |
touch() flips the mode to Dirty and marks the saver dirty |
flush_writes_the_record_and_reports_saved |
A dirty flush writes the file, reports Ok, flips to Saved, and a clean flush is a no-op |
flush_reports_a_write_failure_without_claiming_saved |
A failed write leaves the mode at Failed and the saver dirty for a retry |
maybe_flush_waits_for_the_debounce |
A long debounce defers the write; a zero debounce fires on the next call |
a_dirty_boot_starts_with_the_clock_running |
Constructing dirty (first run) still flushes via maybe_flush — the demo reaches disk without a user edit |
The boot wiring, the save-mode truthfulness (I2.3), and the command
write path. These run against a scratch WorkspaceStore through
ChainNotesApp::at(store) — the testable core of new(), which needs
an eframe CreationContext that tests cannot construct.
| Test | Locks |
|---|---|
first_run_flush_puts_the_demo_on_disk |
First run starts Dirty (the label says "Saving…", not "Saved to disk" for a file that does not exist yet), the first flush creates workspace.json, the mode flips to Saved, and a re-boot loads those demo notes |
boot_after_a_save_reports_saved_not_dirty |
A saved file boots as Saved with no pending write, the status sentence reports the note count, and the selection survives the round-trip |
boot_on_a_corrupt_file_leaves_the_disk_copy_untouched |
A corrupt file boots the demo in memory with mode None — and an idle exit (flush with nothing dirty) leaves the corrupt bytes on disk for the user to recover |
save_mode_labels_are_distinct |
Every SaveMode variant has its own status-bar sentence |
new_note_command_creates_selects_and_marks_dirty |
Command::NewNote through handle() creates a "Untitled" note, selects it, and dirties the saver |
delete_and_undo_commands_round_trip_through_handle |
DeleteSelected then UndoDelete through handle() restores the note and the selection |
toggle_link_command_edges_and_dirty_the_record |
A successful ToggleLink adds the edge and dirties; a refused self-link does not |
select_and_about_commands_do_not_dirty_the_record |
View-only commands (SelectNote, About) change state without marking a save |
The first two boot tests exist because of a bug caught during writing:
new() used to start the first run with dirty = false and
save_mode = Saved, so the status bar claimed "Saved to disk" while no
file existed, and the demo was never written until the user's first edit.
The command tests lock the single write path that every menu, panel, and
future shortcut goes through.
The status sentences.
| Test | Locks |
|---|---|
counts_use_plural_forms |
"Workspace is empty." / "Loaded 1 note." / "Loaded N notes." |
detail_sentences_embed_the_cause |
The parser/OS detail is interpolated into the sentence |
UTC calendar arithmetic is tested without egui or a window.
| Test | Locks |
|---|---|
unix_epoch_is_thursday_january_first |
Unix timestamp conversion and Monday-based weekday numbering |
month_lengths_and_leap_years_are_correct |
Gregorian month lengths, century rules, and leap years |
month_shifting_crosses_year_boundaries |
Previous/next month navigation across year boundaries |
| Test | Locks |
|---|---|
export_writes_a_pdf_with_note_text_and_metadata |
The writer emits a PDF header containing title, metadata, and escaped body text |
- The egui code itself (
app.rsmenu/status drawing,ui/*). There is no widget or headless-egui test yet; the panels are drawn but never pumped in a test. The command logic (handle()) is covered; only the drawing and the panel→command emission wiring is not. Aegui_kittest-style harness is the obvious next step, not yet done. - The debounce wall-clock timing. The 250 ms constant is exercised via
an injectable zero/long debounce (
AutoSaver::with_debounce), not by fast-forwarding a clock;maybe_flushcompares againstInstant::now(). - A real end-to-end save through
eframe::App.ChainNotesApp::newneeds aCreationContextwhose fields arepub(crate)in eframe, so no test drives the app exactly asrun_nativedoes;at(store)is the same code path minus the store-location lookup. - Windows and macOS paths.
dirs::data_dir()is trusted for those platforms; only the Linux resolution is exercised here. - A screen-reader or contrast pass.
accesskitis compiled in by eframe's defaults, but nothing asserts on it (TODO-015). - Native picker and visual editor flows. The PDF picker, View/Edit toggle, calendar clicks, and keyboard shortcuts still need a manual or headless-egui interaction check; their non-UI logic is covered separately.
Put it in the #[cfg(test)] mod tests at the bottom of the module that
owns the behavior — state.rs for record logic, graph.rs / history.rs
for pure graph/undo rules, autosave.rs for debounce/flush, store.rs
for the file seam, app.rs for boot and the command write path. Tests
below the UI boundary need no window. If a test needs a scratch
directory, follow the existing scratch(name) helpers in store.rs,
autosave.rs, and app.rs: they build a unique path under
std::env::temp_dir() and remove any leftover from a prior run.