@@ -0,0 +1,3820 @@
1+//! What the app does over time, as `fold` runs it: the watcher, the
2+//! autosave and the status line (§10.6, §11.2), where the selection goes
3+//! when rows come and go (§8.5), keys on a selection the wheel left out
4+//! of view (§10.1), editor text no save can take when fold ends or
5+//! reverts (§10.6), sync conflicts that come in while you work (§10.7),
6+//! the conflict copies they leave in the outline (§10.1, §12.5), what
7+//! the status line says a verb did (§10.1), the next step it shows once
8+//! that is old, or a key is half typed or does nothing (§10.1), the
9+//! pointer inside a popup, which never reaches the panes behind (§10.1),
10+//! and the help, which scrolls where the screen is short (§10.8).
11+
12+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
13+use fold_tui::app::{node_menu_index, Action, App, EditKeys, Hit};
14+use ratatui::backend::TestBackend;
15+use ratatui::Terminal;
16+use std::time::{Duration, Instant};
17+
18+fn key(code: KeyCode) -> KeyEvent {
19+ KeyEvent::new(code, KeyModifiers::NONE)
20+}
21+
22+fn ctrl(c: char) -> KeyEvent {
23+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
24+}
25+
26+/// A vault in a directory named as a real one would be: tempfile's own
27+/// names start with a dot, which the watcher skips.
28+fn vault(root: &str) -> tempfile::TempDir {
29+ let d = tempfile::Builder::new().prefix("vault").tempdir().unwrap();
30+ std::fs::write(d.path().join("root.md"), root).unwrap();
31+ d
32+}
33+
34+/// The app as `fold` starts it: the watcher, then the startup scan for
35+/// sync-conflict copies, which reads the vault directory.
36+fn start(d: &tempfile::TempDir) -> App {
37+ let mut app = App::new(d.path()).unwrap();
38+ app.start_watcher();
39+ app.merge_on_startup();
40+ app
41+}
42+
43+/// The main loop for `ms` milliseconds with no key pressed; the number of
44+/// reloads it ran.
45+fn idle(app: &mut App, ms: u64) -> usize {
46+ let end = Instant::now() + Duration::from_millis(ms);
47+ let mut reloads = 0;
48+ while Instant::now() < end {
49+ std::thread::sleep(Duration::from_millis(50));
50+ reloads += app.tick() as usize;
51+ }
52+ reloads
53+}
54+
55+fn root(d: &tempfile::TempDir) -> String {
56+ std::fs::read_to_string(d.path().join("root.md")).unwrap()
57+}
58+
59+fn status_line(app: &mut App) -> String {
60+ let (w, h) = (160, 20);
61+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
62+ t.draw(|f| app.draw(f)).unwrap();
63+ let b = t.backend().buffer().clone();
64+ (0..h).map(|y| (0..w).map(|x| b[(x, y)].symbol()).collect::<String>()).find(|l| l.contains("saved")).unwrap_or_default()
65+}
66+
67+/// Watch the vault as strace would: every file the app opens there.
68+fn spy(d: &tempfile::TempDir) -> (impl Sized, impl Fn() -> usize) {
69+ use notify::event::{AccessKind, EventKind};
70+ use notify::Watcher;
71+ let (tx, rx) = std::sync::mpsc::channel();
72+ let mut w = notify::recommended_watcher(move |res| {
73+ let _ = tx.send(res);
74+ })
75+ .unwrap();
76+ w.watch(d.path(), notify::RecursiveMode::Recursive).unwrap();
77+ let opens = move || {
78+ rx.try_iter()
79+ .filter(|e: ¬ify::Result<notify::Event>| {
80+ e.as_ref().is_ok_and(|e| matches!(e.kind, EventKind::Access(AccessKind::Open(_))))
81+ })
82+ .count()
83+ };
84+ (w, opens)
85+}
86+
87+#[test]
88+fn an_idle_app_does_not_reload_on_its_own_reads_or_writes() {
89+ // a reload reads every file; reading one is no change to take in
90+ let d = vault("# Inbox\n\n- [ ] a\n");
91+ let mut app = start(&d);
92+ let (_w, opens) = spy(&d);
93+ assert_eq!(idle(&mut app, 2500), 0, "reloads while idle");
94+ assert_eq!(opens(), 0, "files read while idle");
95+ // nor is what fold wrote itself, seen once its self-write window is over
96+ app.handle_key(key(KeyCode::Char('j')));
97+ app.handle_key(key(KeyCode::Char('x')));
98+ assert_eq!(root(&d), "# Inbox\n\n- [x] a\n");
99+ assert_eq!(idle(&mut app, 1500), 0, "reloads after checking a task");
100+}
101+
102+#[test]
103+fn typing_in_the_editor_is_one_save_not_split_by_reloads() {
104+ let text = "# Inbox\n\nnotes\n";
105+ let d = vault(text);
106+ let mut app = start(&d);
107+ app.set_edit_keys(EditKeys::Normal);
108+ app.handle_key(key(KeyCode::Char('e')));
109+ app.handle_key(key(KeyCode::Down));
110+ app.handle_key(key(KeyCode::Down));
111+ app.handle_key(key(KeyCode::End));
112+ // ten words over some 2.5 s, never pausing long enough to autosave
113+ let typed = " on the quick brown fox and the lazy dog";
114+ for c in typed.chars() {
115+ app.handle_key(key(KeyCode::Char(c)));
116+ std::thread::sleep(Duration::from_millis(60));
117+ assert!(!app.tick(), "reloaded while typing");
118+ }
119+ assert_eq!(root(&d), text, "saved while typing");
120+ app.handle_key(key(KeyCode::Esc));
121+ assert_eq!(root(&d), format!("# Inbox\n\nnotes{}\n", typed));
122+ // one undo takes back all of it
123+ app.handle_key(key(KeyCode::Char('u')));
124+ assert_eq!(root(&d), text);
125+}
126+
127+#[test]
128+fn a_nested_block_cut_in_the_editor_survives_until_it_is_pasted() {
129+ // §5.2: cutting a nested block's title line and pasting it elsewhere
130+ // moves the block, however long the pause between the two
131+ let d = vault("# A\n\n- one\n- task\n- two\n");
132+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
133+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
134+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
135+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
136+ drop(v);
137+ let embed = format!("![[{}]]", id.as_str());
138+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- two\n", embed));
139+ let before = std::fs::read_to_string(&block).unwrap();
140+ let mut app = start(&d);
141+ app.set_edit_keys(EditKeys::Normal);
142+ app.handle_key(key(KeyCode::Char('e')));
143+ for _ in 0..3 {
144+ app.handle_key(key(KeyCode::Down));
145+ }
146+ app.handle_key(ctrl('k'));
147+ // the pause autosaves A without the line; the block waits in transit
148+ let reloads = idle(&mut app, 2000);
149+ assert!(block.exists(), "the cut block was deleted before it was pasted");
150+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
151+ assert_eq!(reloads, 0, "reloads during the pause");
152+ // paste it above "- one"
153+ app.handle_key(key(KeyCode::Up));
154+ app.handle_key(ctrl('v'));
155+ app.handle_key(key(KeyCode::Esc));
156+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n", embed));
157+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
158+}
159+
160+#[test]
161+fn help_and_the_view_toggles_from_the_editor_keep_a_cut_block_to_paste() {
162+ // §5.2: help, the palette and the view toggles write nothing, so a
163+ // block cut in the editor is still moved once pasted after them
164+ let d = vault("# A\n\n- one\n- task\n- two\n");
165+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
166+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
167+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
168+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
169+ drop(v);
170+ let embed = format!("![[{}]]", id.as_str());
171+ let before = std::fs::read_to_string(&block).unwrap();
172+ let mut app = start(&d);
173+ app.set_edit_keys(EditKeys::Normal);
174+ app.handle_key(key(KeyCode::Char('e')));
175+ for _ in 0..3 {
176+ app.handle_key(key(KeyCode::Down));
177+ }
178+ app.handle_key(ctrl('k'));
179+ idle(&mut app, 1000);
180+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
181+ // F1, then Esc back to the editor
182+ app.handle_key(key(KeyCode::F(1)));
183+ assert_eq!(app.mode_pub(), "help");
184+ app.handle_key(key(KeyCode::Esc));
185+ assert!(block.exists(), "F1 deleted the cut block");
186+ // the top bar's ?, closed with its ✕
187+ click_button(&mut app, Action::Help);
188+ assert_eq!(app.mode_pub(), "help");
189+ click_button(&mut app, Action::Close);
190+ assert!(block.exists(), "the ? button deleted the cut block");
191+ // wrap lines from ☰ Commands
192+ click_button(&mut app, Action::Palette);
193+ press(&mut app, "wrap");
194+ app.handle_key(key(KeyCode::Enter));
195+ assert!(block.exists(), "a view toggle deleted the cut block");
196+ assert_eq!(app.mode_pub(), "edit");
197+ // paste it above "- one"
198+ app.handle_key(key(KeyCode::Up));
199+ app.handle_key(ctrl('v'));
200+ app.handle_key(key(KeyCode::Esc));
201+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n", embed));
202+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
203+}
204+
205+/// Alpha open in the editor with its "task", a block of its own, cut and
206+/// a pause after, beside Bravo, a block too: the autosave writes Alpha
207+/// without the embed, and the block waits in transit (§5.2). Bravo's file
208+/// is listed after the cut block's, so deleting that one renumbers it.
209+/// With the two files.
210+fn cut_beside_bravo() -> (tempfile::TempDir, App, std::path::PathBuf, std::path::PathBuf) {
211+ let d = vault(&format!("# Alpha\n\n- one\n![[{}]]\n- two\n\n# ![[{}]]\n", FIRST, LAST));
212+ let block = d.path().join("bacbec~task.md");
213+ let bravo = d.path().join("worzod~bravo.md");
214+ std::fs::write(&block, format!("---\nid: {}\n---\n\n- task\n", FIRST)).unwrap();
215+ std::fs::write(&bravo, format!("---\nid: {}\ntag: old\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST)).unwrap();
216+ let mut app = start(&d);
217+ select(&mut app, "Alpha");
218+ app.set_edit_keys(EditKeys::Normal);
219+ app.handle_key(key(KeyCode::Char('e')));
220+ for _ in 0..3 {
221+ app.handle_key(key(KeyCode::Down));
222+ }
223+ app.handle_key(ctrl('k'));
224+ idle(&mut app, 1000);
225+ assert_eq!(root(&d), format!("# Alpha\n\n- one\n- two\n\n# ![[{}]]\n", LAST));
226+ assert!(block.exists(), "in transit");
227+ (d, app, block, bravo)
228+}
229+
230+/// Run `name` from ☰ Commands.
231+fn command(app: &mut App, name: &str) {
232+ click_button(app, Action::Palette);
233+ press(app, name);
234+ app.handle_key(key(KeyCode::Enter));
235+}
236+
237+#[test]
238+fn copy_zoom_and_what_opens_a_prompt_from_the_editor_keep_a_cut_block_to_paste() {
239+ // §5.2: a copy and a zoom write nothing, nor do a prompt and the
240+ // property form before their OK: run from a row's menu, a button, ☰
241+ // Commands or a double-click while editing, a block cut in the editor
242+ // is still moved once pasted after them
243+ let cases = ["Copy", "Zoom in, out", "Properties…", "Move to…", "+ Capture", "Capture task", "Go to…", "double-click"];
244+ for how in cases {
245+ let (d, mut app, block, _) = cut_beside_bravo();
246+ let text = std::fs::read_to_string(&block).unwrap();
247+ match how {
248+ "Copy" => {
249+ right_click(&mut app, "bravo one", Action::Copy);
250+ assert!(said(&mut app).starts_with("copied “bravo one”"), "{}", said(&mut app));
251+ }
252+ "Zoom in, out" => {
253+ right_click(&mut app, "Bravo", Action::Zoom);
254+ assert!(screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
255+ assert!(block.exists(), "Zoom in deleted the cut block");
256+ command(&mut app, "Zoom out");
257+ assert!(!screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
258+ }
259+ "Properties…" => {
260+ right_click(&mut app, "Bravo", Action::Props);
261+ assert_eq!(app.mode_pub(), "props");
262+ app.handle_key(key(KeyCode::Esc));
263+ }
264+ "Move to…" => {
265+ right_click(&mut app, "bravo one", Action::Refile);
266+ press(&mut app, "Alpha");
267+ app.handle_key(key(KeyCode::Esc));
268+ }
269+ "+ Capture" => {
270+ click_button(&mut app, Action::Capture);
271+ press(&mut app, "milk");
272+ app.handle_key(key(KeyCode::Esc));
273+ }
274+ "Capture task" => {
275+ command(&mut app, "Capture task");
276+ press(&mut app, "milk");
277+ click_button(&mut app, Action::Close);
278+ }
279+ "Go to…" => {
280+ command(&mut app, "Go to");
281+ press(&mut app, "bravo two");
282+ app.handle_key(key(KeyCode::Enter));
283+ assert_eq!(selected(&app), "bravo two");
284+ }
285+ _ => {
286+ let (x, y) = find(&frame(&mut app), "Bravo").unwrap();
287+ click_at(&mut app, x, y, MouseButton::Left);
288+ click_at(&mut app, x, y, MouseButton::Left);
289+ assert!(screen(&mut app).contains("› Bravo"), "{}", screen(&mut app));
290+ }
291+ }
292+ assert!(block.exists(), "{}: the cut block was deleted", how);
293+ assert_eq!(app.mode_pub(), "edit", "{}", how);
294+ // paste it above "- one"
295+ app.handle_key(key(KeyCode::Up));
296+ app.handle_key(ctrl('v'));
297+ app.handle_key(key(KeyCode::Esc));
298+ let moved = format!("# Alpha\n\n![[{}]]\n- one\n- two\n\n# ![[{}]]\n", FIRST, LAST);
299+ assert_eq!(root(&d), moved, "{}: pasted as plain text", how);
300+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", how);
301+ }
302+}
303+
304+#[test]
305+fn a_prompt_or_the_property_form_opened_over_a_cut_block_writes_where_it_was_opened() {
306+ // §5.2, §10.6: what writes from a prompt or the property form saves
307+ // the editor first, which deletes a block cut there and renumbers the
308+ // files after its own, Bravo's here: the pick and the form's node are
309+ // found again, and the write goes to Bravo
310+ for how in ["Move to… OK", "Add a property", "Delete a property"] {
311+ let (d, mut app, block, bravo) = cut_beside_bravo();
312+ match how {
313+ "Move to… OK" => {
314+ right_click(&mut app, "two", Action::Refile);
315+ press(&mut app, "Bravo");
316+ click_button(&mut app, Action::PromptOk);
317+ assert_eq!(root(&d), format!("# Alpha\n\n- one\n\n# ![[{}]]\n", LAST));
318+ }
319+ "Add a property" => {
320+ right_click(&mut app, "Bravo", Action::Props);
321+ click_button(&mut app, Action::PropAdd);
322+ press(&mut app, "owner");
323+ app.handle_key(key(KeyCode::Enter));
324+ press(&mut app, "me");
325+ app.handle_key(key(KeyCode::Enter));
326+ }
327+ _ => {
328+ right_click(&mut app, "Bravo", Action::Props);
329+ press(&mut app, "d");
330+ }
331+ }
332+ assert!(!block.exists(), "{}: the cut block outlived the write", how);
333+ let text = std::fs::read_to_string(&bravo).unwrap();
334+ let want = match how {
335+ "Move to… OK" => format!("---\nid: {}\ntag: old\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n- two\n", LAST),
336+ "Add a property" => format!("---\nid: {}\ntag: old\nowner: me\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST),
337+ _ => format!("---\nid: {}\n---\n\n# Bravo\n\n- bravo one\n- bravo two\n", LAST),
338+ };
339+ assert_eq!(text, want, "{}: {}", how, said(&mut app));
340+ }
341+}
342+
343+#[test]
344+fn a_change_from_outside_to_a_file_the_editor_does_not_hold_keeps_a_cut_block_to_paste() {
345+ cut_then_the_phone_writes_the_inbox(false);
346+}
347+
348+#[test]
349+fn a_change_from_outside_beside_a_copy_the_merge_left_alone_keeps_a_cut_block_to_paste() {
350+ cut_then_the_phone_writes_the_inbox(true);
351+}
352+
353+/// §5.2, §11.2: the phone writing the Inbox's own file leaves the editor
354+/// on A as it is, so a block cut there is still moved once pasted; beside
355+/// the conflict copy of an ignored file (`copy`) too, which the merge
356+/// leaves alone.
357+fn cut_then_the_phone_writes_the_inbox(copy: bool) {
358+ let d = vault("# A\n\n- one\n- task\n- two\n\n# Inbox\n\n- [ ] a\n");
359+ if copy {
360+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
361+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
362+ }
363+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
364+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
365+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
366+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
367+ let i = v.find_by_path(&["Inbox".into()]).unwrap();
368+ let inbox_id = fold_core::ops::make_block(&mut v, i).unwrap();
369+ let inbox = v.dir.join(&v.tree.files[v.tree.block_by_id(&inbox_id).unwrap().0].path);
370+ drop(v);
371+ let embed = format!("![[{}]]", id.as_str());
372+ let inbox_embed = format!("# ![[{}]]", inbox_id.as_str());
373+ let before = std::fs::read_to_string(&block).unwrap();
374+ let mut app = start(&d);
375+ app.set_edit_keys(EditKeys::Normal);
376+ app.handle_key(key(KeyCode::Char('e')));
377+ for _ in 0..3 {
378+ app.handle_key(key(KeyCode::Down));
379+ }
380+ app.handle_key(ctrl('k'));
381+ idle(&mut app, 1000);
382+ assert_eq!(root(&d), format!("# A\n\n- one\n- two\n\n{}\n", inbox_embed));
383+ // the phone appends to the Inbox
384+ {
385+ use std::io::Write;
386+ let mut f = std::fs::OpenOptions::new().append(true).open(&inbox).unwrap();
387+ f.write_all(b"- [ ] from phone\n").unwrap();
388+ }
389+ until_reload(&mut app);
390+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
391+ assert!(block.exists(), "a change to another file deleted the cut block");
392+ assert_eq!(app.mode_pub(), "edit");
393+ // paste it above "- one"
394+ app.handle_key(key(KeyCode::Up));
395+ app.handle_key(ctrl('v'));
396+ app.handle_key(key(KeyCode::Esc));
397+ assert_eq!(root(&d), format!("# A\n\n{}\n- one\n- two\n\n{}\n", embed, inbox_embed));
398+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
399+}
400+
401+#[test]
402+fn a_change_from_outside_to_another_node_of_the_editor_s_file_keeps_a_cut_block_to_paste() {
403+ // §5.2, §11.2: the phone adds to the Inbox in root.md, the file the
404+ // editor on A is in, after A or before it: what the editor shows is as
405+ // it was, so a block cut there is still moved once pasted
406+ for inbox_first in [false, true] {
407+ let (a, inbox) = ("# A\n\n- one\n- task\n- two\n", "# Inbox\n\n- [ ] a\n");
408+ let text = if inbox_first { format!("{}\n{}", inbox, a) } else { format!("{}\n{}", a, inbox) };
409+ let d = vault(&text);
410+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
411+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
412+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
413+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
414+ drop(v);
415+ let embed = format!("![[{}]]", id.as_str());
416+ let before = std::fs::read_to_string(&block).unwrap();
417+ let mut app = start(&d);
418+ select(&mut app, "A");
419+ app.set_edit_keys(EditKeys::Normal);
420+ app.handle_key(key(KeyCode::Char('e')));
421+ for _ in 0..3 {
422+ app.handle_key(key(KeyCode::Down));
423+ }
424+ app.handle_key(ctrl('k'));
425+ idle(&mut app, 1000);
426+ assert_eq!(root(&d), text.replace("- task\n", ""), "inbox first: {}", inbox_first);
427+ // the phone adds to the Inbox
428+ let phone = text.replace("- task\n", "").replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n");
429+ std::fs::write(d.path().join("root.md"), &phone).unwrap();
430+ until_reload(&mut app);
431+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
432+ assert!(block.exists(), "inbox first: {}: the cut block was deleted", inbox_first);
433+ assert_eq!(app.mode_pub(), "edit");
434+ // paste it above "- one"
435+ app.handle_key(key(KeyCode::Up));
436+ app.handle_key(ctrl('v'));
437+ app.handle_key(key(KeyCode::Esc));
438+ let moved = phone.replace("- one\n", &format!("{}\n- one\n", embed));
439+ assert_eq!(root(&d), moved, "inbox first: {}", inbox_first);
440+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before, "inbox first: {}", inbox_first);
441+ }
442+}
443+
444+#[test]
445+fn a_copy_merged_into_the_node_the_editor_shows_deletes_a_block_cut_there() {
446+ // §5.2, §11.2: the merge changes A, so the editor is re-rendered and
447+ // the cut line can no longer be pasted as the block: it is deleted,
448+ // never left in a file embedded nowhere
449+ let d = vault("# A\n\n- one\n- task\n- two\n\n# B\n\nb\n");
450+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
451+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
452+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
453+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
454+ drop(v);
455+ let mut app = start(&d);
456+ app.set_edit_keys(EditKeys::Normal);
457+ app.handle_key(key(KeyCode::Char('e')));
458+ for _ in 0..3 {
459+ app.handle_key(key(KeyCode::Down));
460+ }
461+ app.handle_key(ctrl('k'));
462+ idle(&mut app, 1000);
463+ assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# B\n\nb\n");
464+ assert!(block.exists(), "in transit");
465+ std::fs::write(
466+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
467+ "# A\n\n- one\n- two\n- from phone\n\n# B\n\nb\n",
468+ )
469+ .unwrap();
470+ until_reload(&mut app);
471+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
472+ let merged = root(&d);
473+ assert!(merged.contains("- from phone\n"), "{}", merged);
474+ assert!(!block.exists(), "a cut block was left embedded nowhere");
475+ assert_eq!(app.mode_pub(), "edit");
476+ // the line pasted back is text of A
477+ app.handle_key(key(KeyCode::Up));
478+ app.handle_key(ctrl('v'));
479+ app.handle_key(key(KeyCode::Esc));
480+ assert_eq!(root(&d), merged.replace("- one\n", "- task\n- one\n"));
481+ // one undo takes back the paste; the block went in the step that cut
482+ // it, which the merge refuses to undo, so its file stays in the trash
483+ // rather than come back embedded nowhere (§10.10)
484+ app.handle_key(key(KeyCode::Char('u')));
485+ assert_eq!(root(&d), merged);
486+ assert!(!block.exists());
487+ app.handle_key(key(KeyCode::Char('u')));
488+ assert_eq!(said(&mut app), "undo refused: root.md changed since edit “A”; not overwriting");
489+ assert_eq!(root(&d), merged);
490+ assert!(!block.exists(), "undo put back the block's file embedded nowhere");
491+}
492+
493+#[test]
494+fn a_copy_from_before_a_cut_brings_in_its_own_change_and_the_paste_still_moves_the_block() {
495+ // §5.2, §11.2, §12.4: the phone adds to the Inbox while "task" is cut
496+ // in the editor on A. root.md changed on both sides, so Syncthing
497+ // delivers the phone's as a copy of root.md from before the cut, the
498+ // block's embed still in A. The block is moved here, not put back where
499+ // it was cut: the copy's own change comes in, the editor stays as it
500+ // is, and the paste embeds the block once. With A's text changed on the
501+ // phone too, its conflict copy holds no embed of the block either
502+ for phone_edits_a in [false, true] {
503+ let d = vault("# A\n\n- one\n- task\n- two\n\n# Inbox\n\n- [ ] a\n");
504+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
505+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
506+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
507+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
508+ drop(v);
509+ let embed = format!("![[{}]]", id.as_str());
510+ let embedded = root(&d);
511+ let before = std::fs::read_to_string(&block).unwrap();
512+ let mut app = start(&d);
513+ select(&mut app, "A");
514+ app.set_edit_keys(EditKeys::Normal);
515+ app.handle_key(key(KeyCode::Char('e')));
516+ for _ in 0..3 {
517+ app.handle_key(key(KeyCode::Down));
518+ }
519+ app.handle_key(ctrl('k'));
520+ idle(&mut app, 1000);
521+ assert_eq!(root(&d), "# A\n\n- one\n- two\n\n# Inbox\n\n- [ ] a\n");
522+ let mut phone = embedded.replace("- [ ] a\n", "- [ ] a\n- [ ] from phone\n");
523+ if phone_edits_a {
524+ phone = phone.replace("# A\n\n", "# A\n\nfrom phone too\n\n");
525+ }
526+ land(d.path(), "root.sync-conflict-20260927-101010-PHONE77.md", &phone);
527+ until_reload(&mut app);
528+ let case = format!("A edited on the phone: {}", phone_edits_a);
529+ assert!(app.vault_conflict_files().unwrap().is_empty(), "{}: the copy was not merged", case);
530+ assert!(root(&d).contains("- [ ] a\n- [ ] from phone\n"), "{}: {}", case, root(&d));
531+ assert!(!root(&d).contains(&embed), "{}: the cut block was put back where it was cut: {}", case, root(&d));
532+ assert!(block.exists(), "{}: the cut block was deleted", case);
533+ assert_eq!(app.mode_pub(), "edit", "{}", case);
534+ if phone_edits_a {
535+ assert_eq!(pairs(&mut app), 1, "{}", case);
536+ } else {
537+ assert_eq!(said(&mut app), "↻ changed outside fold: Inbox (+1 item)", "{}", case);
538+ }
539+ // paste it above "- one"
540+ app.handle_key(key(KeyCode::Up));
541+ app.handle_key(ctrl('v'));
542+ app.handle_key(key(KeyCode::Esc));
543+ assert!(root(&d).starts_with(&format!("# A\n\n{}\n- one\n- two\n", embed)), "{}: {}", case, root(&d));
544+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before, "{}", case);
545+ // one embed of the block in the vault, and its line nowhere else
546+ let mut embeds = 0;
547+ for f in std::fs::read_dir(d.path()).unwrap() {
548+ let path = f.unwrap().path();
549+ let text = std::fs::read_to_string(&path).unwrap_or_default();
550+ embeds += text.matches(&embed).count();
551+ assert!(path == block || !text.contains("task"), "{}: pasted as plain text: {}", case, text);
552+ }
553+ assert_eq!(embeds, 1, "{}: the block is embedded {} times", case, embeds);
554+ }
555+}
556+
557+/// A, its "task" a block of its own, open in the editor with that line
558+/// cut and a pause after: the autosave writes A without its embed, and
559+/// the block waits in transit (§5.2). With the block's file, and root.md
560+/// as it was before the cut.
561+fn cut_and_paused() -> (tempfile::TempDir, App, std::path::PathBuf, String) {
562+ let d = vault("# A\n\n- one\n- task\n- two\n");
563+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
564+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
565+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
566+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
567+ drop(v);
568+ let embedded = format!("# A\n\n- one\n![[{}]]\n- two\n", id.as_str());
569+ assert_eq!(root(&d), embedded);
570+ let mut app = start(&d);
571+ app.set_edit_keys(EditKeys::Normal);
572+ app.handle_key(key(KeyCode::Char('e')));
573+ for _ in 0..3 {
574+ app.handle_key(key(KeyCode::Down));
575+ }
576+ app.handle_key(ctrl('k'));
577+ idle(&mut app, 1000);
578+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
579+ assert!(block.exists(), "in transit");
580+ (d, app, block, embedded)
581+}
582+
583+#[test]
584+fn leaving_the_editor_after_the_autosave_deletes_a_cut_block_that_one_undo_puts_back_embedded() {
585+ // §5.2, §10.10: the block is deleted with the save that wrote its
586+ // embed out, so one undo never puts back its file embedded nowhere
587+ let (d, mut app, block, embedded) = cut_and_paused();
588+ let text = std::fs::read_to_string(&block).unwrap();
589+ app.handle_key(key(KeyCode::Esc));
590+ assert_eq!(app.mode_pub(), "normal");
591+ assert!(!block.exists(), "the cut block outlived the editor");
592+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
593+ press(&mut app, "u");
594+ assert_eq!(said(&mut app), "undone: edit “A”");
595+ assert_eq!(root(&d), embedded, "undo put back the block's file embedded nowhere");
596+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text);
597+ // redo deletes it again, embed and file
598+ press(&mut app, "U");
599+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
600+ assert!(!block.exists());
601+}
602+
603+#[test]
604+fn a_cut_block_deleted_after_more_saves_or_on_revert_comes_back_embedded_with_the_undo_of_its_cut() {
605+ // typing saved after the cut, a second cut that lets go of the first,
606+ // or Revert: whichever save deletes the block, undo puts it back with
607+ // the step that cut it (§5.2, §10.10)
608+ for how in ["typing", "second cut", "revert"] {
609+ let (d, mut app, block, embedded) = cut_and_paused();
610+ let cut = root(&d);
611+ match how {
612+ "typing" => {
613+ app.handle_key(key(KeyCode::End));
614+ press(&mut app, " more");
615+ idle(&mut app, 1000);
616+ assert_eq!(root(&d), "# A\n\n- one\n- two more\n");
617+ app.handle_key(key(KeyCode::Esc));
618+ }
619+ "second cut" => {
620+ app.handle_key(ctrl('k'));
621+ idle(&mut app, 1000);
622+ assert_eq!(root(&d), "# A\n\n- one\n");
623+ app.handle_key(key(KeyCode::Esc));
624+ }
625+ _ => app.run_action(Action::EditRevert),
626+ }
627+ assert_eq!(app.mode_pub(), "normal", "{}", how);
628+ assert!(!block.exists(), "{}: the cut block outlived the editor", how);
629+ if how != "revert" {
630+ // the later save is undone on its own, the block still gone
631+ press(&mut app, "u");
632+ assert_eq!(root(&d), cut, "{}", how);
633+ assert!(!block.exists(), "{}: undo put back the block's file embedded nowhere", how);
634+ }
635+ press(&mut app, "u");
636+ assert_eq!(said(&mut app), "undone: edit “A”", "{}", how);
637+ assert_eq!(root(&d), embedded, "{}", how);
638+ assert!(block.exists(), "{}: undo did not put back the cut block's file", how);
639+ }
640+}
641+
642+#[test]
643+fn a_cut_block_deleted_by_a_change_from_outside_is_never_undone_to_a_file_embedded_nowhere() {
644+ // the phone adds to A, the node the editor shows: it is re-rendered
645+ // and the cut block deleted (§11.2), in the entry of the save that
646+ // wrote its embed out, which the change refuses to undo; no undo puts
647+ // back its file without the embed (§5.2, §10.10)
648+ let (d, mut app, block, _) = cut_and_paused();
649+ let phone = "# A\n\n- one\n- two\n- from phone\n";
650+ std::fs::write(d.path().join("root.md"), phone).unwrap();
651+ until_reload(&mut app);
652+ assert!(!block.exists(), "a cut block was left embedded nowhere");
653+ app.handle_key(key(KeyCode::Esc));
654+ assert_eq!(app.mode_pub(), "normal");
655+ for _ in 0..2 {
656+ press(&mut app, "u");
657+ assert_eq!(root(&d), phone, "undo overwrote the change from outside");
658+ assert!(!block.exists(), "undo put back the block's file embedded nowhere: {}", said(&mut app));
659+ }
660+ assert_eq!(said(&mut app), "undo refused: root.md changed since edit “A”; not overwriting");
661+}
662+
663+#[test]
664+fn a_cut_block_pasted_back_after_the_autosave_goes_back_where_it_was_with_one_undo() {
665+ // the saves from the one that wrote the embed out to the one that
666+ // writes it back are one step: no undo stops between them, where the
667+ // block's file is embedded nowhere (§5.2, §10.10)
668+ for how in ["leave", "autosave", "typing"] {
669+ let (d, mut app, block, embedded) = cut_and_paused();
670+ let text = std::fs::read_to_string(&block).unwrap();
671+ let embed = embedded.lines().nth(3).unwrap().to_string();
672+ let mut moved = format!("# A\n\n{}\n- one\n- two\n", embed);
673+ if how == "typing" {
674+ app.handle_key(key(KeyCode::End));
675+ press(&mut app, " more");
676+ idle(&mut app, 1000);
677+ assert_eq!(root(&d), "# A\n\n- one\n- two more\n");
678+ moved = moved.replace("- two", "- two more");
679+ }
680+ // the cut line goes back above "one"
681+ app.handle_key(key(KeyCode::Up));
682+ app.handle_key(ctrl('v'));
683+ if how == "autosave" {
684+ idle(&mut app, 1000);
685+ assert_eq!(root(&d), moved);
686+ }
687+ app.handle_key(key(KeyCode::Esc));
688+ assert_eq!(app.mode_pub(), "normal", "{}", how);
689+ assert_eq!(root(&d), moved, "{}", how);
690+ press(&mut app, "u");
691+ assert_eq!(said(&mut app), "undone: edit “A”", "{}", how);
692+ assert_eq!(root(&d), embedded, "{}: undo left the block's file embedded nowhere", how);
693+ assert_eq!(std::fs::read_to_string(&block).unwrap(), text, "{}", how);
694+ // redo moves it again, as one step
695+ press(&mut app, "U");
696+ assert_eq!(root(&d), moved, "{}", how);
697+ assert!(block.exists(), "{}", how);
698+ }
699+ // pasted back where it was: nothing changed, so nothing to undo
700+ let (d, mut app, block, embedded) = cut_and_paused();
701+ app.handle_key(ctrl('v'));
702+ app.handle_key(key(KeyCode::Esc));
703+ assert_eq!(root(&d), embedded);
704+ press(&mut app, "u");
705+ assert_eq!(said(&mut app), "nothing to undo");
706+ assert_eq!(root(&d), embedded);
707+ assert!(block.exists());
708+}
709+
710+#[test]
711+fn a_cut_block_pasted_back_where_a_save_is_refused_first_still_goes_back_with_one_undo() {
712+ // pasted above " - sub", which would nest under its embed: the save
713+ // is refused (§4.7) until the line is outdented, and the block waits
714+ // with its line back and its embed not written (§5.2, §10.10)
715+ let d = vault("# A\n\n- one\n - sub\n- task\n- two\n");
716+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
717+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
718+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
719+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
720+ drop(v);
721+ let embed = format!("![[{}]]", id.as_str());
722+ let embedded = format!("# A\n\n- one\n - sub\n{}\n- two\n", embed);
723+ assert_eq!(root(&d), embedded);
724+ let mut app = start(&d);
725+ app.set_edit_keys(EditKeys::Normal);
726+ app.handle_key(key(KeyCode::Char('e')));
727+ for _ in 0..4 {
728+ app.handle_key(key(KeyCode::Down));
729+ }
730+ app.handle_key(ctrl('k'));
731+ idle(&mut app, 1000);
732+ assert_eq!(root(&d), "# A\n\n- one\n - sub\n- two\n");
733+ app.handle_key(key(KeyCode::Up));
734+ app.handle_key(ctrl('v'));
735+ idle(&mut app, 1000);
736+ assert!(said(&mut app).contains("not saved"), "{}", said(&mut app));
737+ assert_eq!(root(&d), "# A\n\n- one\n - sub\n- two\n");
738+ // " - sub", under the cursor, outdented, and the save goes through
739+ app.handle_key(key(KeyCode::BackTab));
740+ app.handle_key(key(KeyCode::Esc));
741+ assert_eq!(app.mode_pub(), "normal");
742+ assert_eq!(root(&d), format!("# A\n\n- one\n{}\n- sub\n- two\n", embed));
743+ press(&mut app, "u");
744+ assert_eq!(said(&mut app), "undone: edit “A”");
745+ assert_eq!(root(&d), embedded, "undo left the block's file embedded nowhere");
746+ assert!(block.exists());
747+}
748+
749+#[test]
750+fn a_change_from_outside_is_taken_in_and_announced() {
751+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
752+ let mut app = start(&d);
753+ idle(&mut app, 300);
754+ // another program appends a task, as `echo >> root.md` would
755+ {
756+ use std::io::Write;
757+ let mut f = std::fs::OpenOptions::new().append(true).open(d.path().join("root.md")).unwrap();
758+ f.write_all(b"- [ ] phone-added task\n").unwrap();
759+ }
760+ let end = Instant::now() + Duration::from_secs(5);
761+ let mut reloads = 0;
762+ while reloads == 0 && Instant::now() < end {
763+ std::thread::sleep(Duration::from_millis(50));
764+ reloads += app.tick() as usize;
765+ }
766+ assert_eq!(reloads, 1, "the change was not taken in");
767+ let titles: Vec<String> = app.rows().iter().map(|r| app.title_of(r.nref)).collect();
768+ assert!(titles.contains(&"phone-added task".to_string()), "{:?}", titles);
769+ let s = status_line(&mut app);
770+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
771+ assert!(!s.contains("NAS"), "{}", s);
772+ // and taken in once
773+ assert_eq!(idle(&mut app, 1000), 0);
774+}
775+
776+#[test]
777+fn a_change_from_outside_while_editing_saves_the_editor_first_and_says_both() {
778+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
779+ let mut app = start(&d);
780+ app.set_edit_keys(EditKeys::Normal);
781+ app.handle_key(key(KeyCode::Char('e')));
782+ app.handle_key(key(KeyCode::End));
783+ app.handle_key(key(KeyCode::Char('!')));
784+ {
785+ use std::io::Write;
786+ let mut f = std::fs::OpenOptions::new().append(true).open(d.path().join("root.md")).unwrap();
787+ f.write_all(b"- [ ] phone-added task\n").unwrap();
788+ }
789+ // the reload comes before the autosave would
790+ let end = Instant::now() + Duration::from_millis(700);
791+ let mut reloads = 0;
792+ while reloads == 0 && Instant::now() < end {
793+ std::thread::sleep(Duration::from_millis(20));
794+ reloads += app.tick() as usize;
795+ }
796+ assert_eq!(reloads, 1, "the change was not taken in");
797+ assert_eq!(root(&d), "# NAS!\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] phone-added task\n");
798+ // the typing is not what came from outside
799+ let s = status_line(&mut app);
800+ assert!(s.contains("↻ typing saved; changed outside fold: Inbox (+1 item)"), "{}", s);
801+}
802+
803+#[test]
804+fn a_block_file_renamed_outside_fold_is_still_not_overwritten_over_a_change_there() {
805+ // `fold check --fix` renames the file of "task", a block the editor
806+ // on A shows, its text as it was; the phone then adds to it while the
807+ // editor has typing unsaved, typed before the rename or after: the
808+ // save is refused as it would be under the old name (§5.2 step 5),
809+ // never written over the phone's line once the vault has read it
810+ for typed_first in [false, true] {
811+ let d = vault("# A\n\n- one\n- task\n- two\n");
812+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
813+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
814+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
815+ let old = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
816+ drop(v);
817+ let renamed = old.with_file_name(old.file_name().unwrap().to_string_lossy().replace("~task.md", "~task-renamed.md"));
818+ let mut app = start(&d);
819+ app.set_edit_keys(EditKeys::Normal);
820+ app.handle_key(key(KeyCode::Char('e')));
821+ for _ in 0..3 {
822+ app.handle_key(key(KeyCode::Down));
823+ }
824+ app.handle_key(key(KeyCode::End));
825+ if !typed_first {
826+ std::fs::rename(&old, &renamed).unwrap();
827+ until_reload(&mut app);
828+ assert_eq!(app.mode_pub(), "edit");
829+ }
830+ press(&mut app, " XYZ");
831+ if typed_first {
832+ std::fs::rename(&old, &renamed).unwrap();
833+ }
834+ let theirs = std::fs::read_to_string(&renamed).unwrap() + " - phone child\n";
835+ std::fs::write(&renamed, &theirs).unwrap();
836+ until_reload(&mut app);
837+ // the autosave, some pauses on
838+ idle(&mut app, 1600);
839+ assert_eq!(std::fs::read_to_string(&renamed).unwrap(), theirs, "typed first: {}", typed_first);
840+ assert!(said(&mut app).contains("changed on disk; not overwriting"), "typed first: {}: {}", typed_first, said(&mut app));
841+ assert_eq!(app.mode_pub(), "edit");
842+ }
843+}
844+
845+#[test]
846+fn where_the_bar_is_short_what_came_in_gives_way_to_the_typing_saved_for_it() {
847+ // 80×24 while editing: the EDIT badge leaves 53 columns, and with done
848+ // hidden 40, 49 once the file gives way
849+ for hide in [false, true] {
850+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
851+ let mut app = App::new(d.path()).unwrap();
852+ app.set_edit_keys(EditKeys::Normal);
853+ if hide {
854+ press(&mut app, "zd");
855+ }
856+ app.handle_key(key(KeyCode::Char('e')));
857+ app.handle_key(key(KeyCode::End));
858+ press(&mut app, " typed");
859+ std::fs::write(d.path().join("root.md"), root(&d) + "- [ ] phone note\n").unwrap();
860+ app.reload_external();
861+ assert_eq!(app.mode_pub(), "edit");
862+ let s = status80(&mut app);
863+ if hide {
864+ assert!(s.starts_with(" EDIT ↻ typing saved; changed outside fold: Inbox (+1 … done hidden ✓ saved "), "{}", s);
865+ } else {
866+ assert!(s.starts_with(" EDIT ↻ typing saved; changed outside fold: Inbox (+1 item) root.md "), "{}", s);
867+ }
868+ }
869+}
870+
871+/// The main loop until it has run a reload, for up to 5 s.
872+fn until_reload(app: &mut App) {
873+ let end = Instant::now() + Duration::from_secs(5);
874+ while Instant::now() < end {
875+ std::thread::sleep(Duration::from_millis(50));
876+ if app.tick() {
877+ return;
878+ }
879+ }
880+ panic!("the change was not taken in");
881+}
882+
883+#[test]
884+fn a_change_from_outside_beside_a_copy_the_merge_left_alone_is_announced() {
885+ // notes.md has no id: the copy of an ignored file, which the merge
886+ // leaves alone (§12.2), is in the vault for good
887+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
888+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
889+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
890+ let mut app = start(&d);
891+ // nothing came in: the greeting stays
892+ assert!(status_line(&mut app).contains("? help"), "{}", status_line(&mut app));
893+ idle(&mut app, 300);
894+ for task in ["from phone", "from laptop"] {
895+ let text = root(&d);
896+ std::fs::write(d.path().join("root.md"), format!("{}- [ ] {}\n", text, task)).unwrap();
897+ until_reload(&mut app);
898+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == task));
899+ let s = status_line(&mut app);
900+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
901+ assert!(!s.contains("notes") && !s.contains("merged"), "{}", s);
902+ }
903+}
904+
905+#[test]
906+fn a_change_from_outside_to_the_file_typed_in_beside_a_copy_the_merge_left_alone_is_announced() {
907+ // the editor's save takes the phone's line into root.md before the
908+ // merge runs for the copy it leaves alone (§11.2): what came in is still
909+ // what the files held against what was read
910+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
911+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
912+ std::fs::write(d.path().join("notes.sync-conflict-20260926-150000-PHONE.md"), "theirs\n").unwrap();
913+ let mut app = App::new(d.path()).unwrap();
914+ app.set_edit_keys(EditKeys::Normal);
915+ app.handle_key(key(KeyCode::Char('e')));
916+ app.handle_key(key(KeyCode::End));
917+ press(&mut app, " typed");
918+ std::fs::write(d.path().join("root.md"), root(&d) + "- [ ] phone note\n").unwrap();
919+ app.reload_external();
920+ assert_eq!(root(&d), "# NAS typed\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] phone note\n");
921+ assert_eq!(app.mode_pub(), "edit");
922+ assert_eq!(said(&mut app), "↻ typing saved; changed outside fold: Inbox (+1 item)");
923+}
924+
925+#[test]
926+fn a_file_typed_in_and_its_copy_that_land_together_are_both_announced() {
927+ // Syncthing delivers the laptop's root.md and the phone's copy of it
928+ // while the editor adds to NAS: the save takes the laptop's line in, the
929+ // merge the phone's (§11.2), and both came in from outside
930+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
931+ let mut app = App::new(d.path()).unwrap();
932+ app.set_edit_keys(EditKeys::Normal);
933+ app.handle_key(key(KeyCode::Char('e')));
934+ app.handle_key(key(KeyCode::Down));
935+ app.handle_key(key(KeyCode::Down));
936+ app.handle_key(key(KeyCode::End));
937+ app.handle_key(key(KeyCode::Enter));
938+ press(&mut app, "- cables");
939+ let text = root(&d);
940+ std::fs::write(d.path().join("root.md"), text.clone() + "- [ ] from laptop\n").unwrap();
941+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text + "- [ ] from phone\n").unwrap();
942+ app.reload_external();
943+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
944+ assert_eq!(pairs(&mut app), 0);
945+ let r = root(&d);
946+ assert!(r.contains("- cables\n") && r.contains("- [ ] from laptop\n") && r.contains("- [ ] from phone\n"), "{}", r);
947+ assert_eq!(app.mode_pub(), "edit");
948+ assert_eq!(said(&mut app), "↻ typing saved; changed outside fold: Inbox (+2 items)");
949+}
950+
951+#[test]
952+fn a_copy_merged_without_a_pair_is_announced_as_what_came_in() {
953+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
954+ let mut app = start(&d);
955+ idle(&mut app, 300);
956+ std::fs::write(
957+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
958+ "# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] from phone\n",
959+ )
960+ .unwrap();
961+ until_reload(&mut app);
962+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
963+ assert!(root(&d).contains("- [ ] from phone\n"), "{}", root(&d));
964+ assert_eq!(pairs(&mut app), 0);
965+ assert_eq!(app.mode_pub(), "normal");
966+ let s = status_line(&mut app);
967+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
968+ assert!(!s.contains("sync-conflict") && !s.contains("pair"), "{}", s);
969+}
970+
971+#[test]
972+fn a_new_copy_that_changes_nothing_is_not_announced() {
973+ // §11.2: a copy the same as its file, which the merge trashes, and the
974+ // copy of an ignored file (notes.md has no id), which it leaves alone,
975+ // bring nothing in: the greeting stays, as it does at startup
976+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
977+ std::fs::write(d.path().join("notes.md"), "mine\n").unwrap();
978+ let mut app = start(&d);
979+ idle(&mut app, 300);
980+ let rows = |app: &App| app.rows().iter().map(|r| app.title_of(r.nref)).collect::<Vec<_>>();
981+ let was = rows(&app);
982+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
983+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), root(&d)).unwrap();
984+ until_reload(&mut app);
985+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
986+ assert_eq!(rows(&app), was);
987+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
988+ std::fs::write(d.path().join("notes.sync-conflict-20260927-100000-PHONE.md"), "theirs\n").unwrap();
989+ until_reload(&mut app);
990+ assert_eq!(rows(&app), was);
991+ assert!(said(&mut app).ends_with("? help"), "{}", said(&mut app));
992+ // nor, while typing, is a save said to have made way for it
993+ app.set_edit_keys(EditKeys::Normal);
994+ app.handle_key(key(KeyCode::Char('e')));
995+ app.handle_key(key(KeyCode::End));
996+ app.handle_key(key(KeyCode::Char('!')));
997+ let editing = said(&mut app);
998+ std::fs::write(d.path().join("notes.sync-conflict-20260927-110000-LAPTOP.md"), "theirs too\n").unwrap();
999+ until_reload(&mut app);
1000+ assert_eq!(app.mode_pub(), "edit");
1001+ let s = said(&mut app);
1002+ assert!(!s.contains("changed outside") && !s.contains("typing saved"), "{}", s);
1003+ assert_eq!(s, editing);
1004+}
1005+
1006+#[test]
1007+fn a_copy_merged_at_startup_without_a_pair_is_announced_as_what_came_in() {
1008+ let d = vault("# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n");
1009+ std::fs::write(
1010+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
1011+ "# NAS\n\n- disks\n\n# Inbox\n\n- [ ] a\n- [ ] from phone\n",
1012+ )
1013+ .unwrap();
1014+ let mut app = start(&d);
1015+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not merged");
1016+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from phone"));
1017+ let s = status_line(&mut app);
1018+ assert!(s.contains("↻ changed outside fold: Inbox (+1 item)"), "{}", s);
1019+ assert!(!s.contains("sync-conflict") && !s.contains("pair"), "{}", s);
1020+ assert_eq!(idle(&mut app, 1000), 0);
1021+}
1022+
1023+/// A file as Syncthing puts one in the vault: written to a dot-temp file,
1024+/// then renamed into place.
1025+fn land(dir: &std::path::Path, name: &str, text: &str) {
1026+ let tmp = dir.join(format!(".syncthing.{}.tmp", name));
1027+ std::fs::write(&tmp, text).unwrap();
1028+ std::fs::rename(&tmp, dir.join(name)).unwrap();
1029+}
1030+
1031+#[test]
1032+fn a_copy_that_lands_while_the_one_before_is_merged_is_merged_next() {
1033+ // Syncthing delivers a batch of copies a moment apart: one that lands
1034+ // while a reload merges the one before is new to the next (§11.2),
1035+ // not seen with it. A big vault makes the reload long enough to land in
1036+ let backlog: String = (0..5000).map(|i| format!("- [ ] backlog {}\n", i)).collect();
1037+ let mut text = format!("# Inbox\n\n- [ ] a\n\n# Backlog\n\n{}\n# Blocks\n\n", backlog);
1038+ let d = vault("");
1039+ for i in 0..500u64 {
1040+ let id = fold_core::ident::Id::from_bytes((i + 1).to_be_bytes());
1041+ let name = fold_core::ident::filename(id.as_str(), &format!("block-{}", i));
1042+ std::fs::write(d.path().join(name), format!("---\nid: {}\n---\n\n- [ ] block {}\n", id, i)).unwrap();
1043+ text.push_str(&format!("![[{}]]\n", id));
1044+ }
1045+ std::fs::write(d.path().join("root.md"), &text).unwrap();
1046+ let mut app = start(&d);
1047+ idle(&mut app, 300);
1048+ let phone = "root.sync-conflict-20260927-120000-PHONE.md";
1049+ land(d.path(), phone, &text.replace("- [ ] a\n", "- [ ] a\n- [ ] from A\n"));
1050+ // the tablet's copy lands as soon as the merge has taken the phone's
1051+ let tablet = {
1052+ let dir = d.path().to_path_buf();
1053+ let text = text.replace("- [ ] a\n", "- [ ] a\n- [ ] from B\n");
1054+ std::thread::spawn(move || {
1055+ let end = Instant::now() + Duration::from_secs(10);
1056+ while dir.join(phone).exists() && Instant::now() < end {
1057+ std::thread::yield_now();
1058+ }
1059+ land(&dir, "root.sync-conflict-20260927-120005-TABLET.md", &text);
1060+ })
1061+ };
1062+ until_reload(&mut app);
1063+ tablet.join().unwrap();
1064+ assert!(root(&d).contains("- [ ] from A\n"), "the phone's copy was not merged");
1065+ let end = Instant::now() + Duration::from_secs(5);
1066+ while !app.vault_conflict_files().unwrap().is_empty() && Instant::now() < end {
1067+ std::thread::sleep(Duration::from_millis(50));
1068+ app.tick();
1069+ }
1070+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the tablet's copy waits unmerged");
1071+ assert!(root(&d).contains("- [ ] from B\n"), "{}", &root(&d)[..60]);
1072+ assert!(app.rows().iter().any(|r| app.title_of(r.nref) == "from B"));
1073+}
1074+
1075+// ------------------------------------------------------------ hiding done
1076+
1077+/// Two lists with done tasks above the cursor's rows, as in the sample
1078+/// vault.
1079+const TASKS: &str = "# NAS\n\n- [x] Replace fan\n- [ ] Snapshot policy\n\n# Networking\n\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n\n# Atlas\n\nQuarterly planning notes.\nDeadline is end of month.\n\n- [ ] Draft the RFC\n- [ ] Review PR\n- [x] Kickoff meeting\n\n# Reading list\n\n- The Rust Book\n";
1080+
1081+fn press(app: &mut App, keys: &str) {
1082+ for c in keys.chars() {
1083+ app.handle_key(key(KeyCode::Char(c)));
1084+ }
1085+}
1086+
1087+/// The selected node's title.
1088+fn selected(app: &App) -> String {
1089+ app.current().map(|r| app.title_of(r)).unwrap_or_default()
1090+}
1091+
1092+/// Put the cursor on the row titled `title`.
1093+fn select(app: &mut App, title: &str) {
1094+ app.cursor = app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap();
1095+}
1096+
1097+fn draw(app: &mut App) {
1098+ let mut t = Terminal::new(TestBackend::new(120, 32)).unwrap();
1099+ t.draw(|f| app.draw(f)).unwrap();
1100+}
1101+
1102+/// Draw, then click the button the frame drew for `a`.
1103+fn click_button(app: &mut App, a: Action) {
1104+ draw(app);
1105+ let (x, y) = app.button_pos(a).unwrap_or_else(|| panic!("no button {:?}", a));
1106+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
1107+}
1108+
1109+#[test]
1110+fn hiding_and_showing_done_keeps_the_selected_node() {
1111+ let d = vault(TASKS);
1112+ let mut app = App::new(d.path()).unwrap();
1113+ select(&mut app, "Draft the RFC");
1114+ // two done tasks above it go: the row number changes, the node does not
1115+ press(&mut app, "zd");
1116+ assert_eq!(selected(&app), "Draft the RFC");
1117+ press(&mut app, "zd");
1118+ assert_eq!(selected(&app), "Draft the RFC");
1119+ // the next verb acts on it
1120+ press(&mut app, "x");
1121+ assert!(root(&d).contains("- [x] Draft the RFC"), "{}", root(&d));
1122+}
1123+
1124+#[test]
1125+fn hiding_the_selected_done_task_selects_its_next_shown_sibling() {
1126+ let d = vault(TASKS);
1127+ let mut app = App::new(d.path()).unwrap();
1128+ select(&mut app, "patch panel");
1129+ press(&mut app, "zd");
1130+ assert_eq!(selected(&app), "rack");
1131+ press(&mut app, "zd");
1132+ select(&mut app, "Replace fan");
1133+ press(&mut app, "zd");
1134+ assert_eq!(selected(&app), "Snapshot policy");
1135+ // the last one of its list: the row above
1136+ press(&mut app, "zd");
1137+ select(&mut app, "Kickoff meeting");
1138+ press(&mut app, "zd");
1139+ assert_eq!(selected(&app), "Review PR");
1140+}
1141+
1142+#[test]
1143+fn hiding_done_skips_done_siblings_and_leaves_a_done_task_s_children() {
1144+ let d = vault("# A\n\n- [x] Order parts\n - [ ] check fan model\n- [x] Call shop\n- [ ] Fit it\n- [ ] Test it\n");
1145+ let mut app = App::new(d.path()).unwrap();
1146+ // under a done task, it goes with it: the task's next shown sibling
1147+ select(&mut app, "check fan model");
1148+ press(&mut app, "zd");
1149+ assert_eq!(selected(&app), "Fit it");
1150+ press(&mut app, "zd");
1151+ select(&mut app, "Order parts");
1152+ press(&mut app, "zd");
1153+ assert_eq!(selected(&app), "Fit it");
1154+}
1155+
1156+#[test]
1157+fn hiding_done_from_the_palette_or_the_status_bar_keeps_the_selected_node() {
1158+ let d = vault(TASKS);
1159+ let mut app = App::new(d.path()).unwrap();
1160+ select(&mut app, "Draft the RFC");
1161+ press(&mut app, ":hide");
1162+ app.handle_key(key(KeyCode::Enter));
1163+ assert!(app.action_on(Action::HideDone).unwrap());
1164+ assert_eq!(selected(&app), "Draft the RFC");
1165+ // *done hidden* in the status bar shows them again
1166+ click_button(&mut app, Action::HideDone);
1167+ assert!(!app.action_on(Action::HideDone).unwrap());
1168+ assert_eq!(selected(&app), "Draft the RFC");
1169+}
1170+
1171+#[test]
1172+fn hiding_done_keeps_the_reading_pane_on_its_node() {
1173+ let d = vault(TASKS);
1174+ let mut app = App::new(d.path()).unwrap();
1175+ app.show_reading = true;
1176+ select(&mut app, "Atlas");
1177+ app.handle_key(key(KeyCode::Tab));
1178+ press(&mut app, "jj");
1179+ draw(&mut app);
1180+ let at = app.read_cursor_pub();
1181+ assert!(at > 0);
1182+ press(&mut app, "zd");
1183+ draw(&mut app);
1184+ assert_eq!(selected(&app), "Atlas");
1185+ assert_eq!(app.read_cursor_pub(), at, "the reading cursor went back to the top");
1186+}
1187+
1188+// ------------------------------------------------------------ out of view
1189+
1190+/// A list longer than the screen, as in the report: `# Tasks` and 80
1191+/// tasks under it.
1192+fn long_list() -> String {
1193+ let tasks: String = (1..=80).map(|i| format!("- [ ] task number {}\n", i)).collect();
1194+ format!("# Tasks\n\n{}", tasks)
1195+}
1196+
1197+fn has_line(d: &tempfile::TempDir, line: &str) -> bool {
1198+ root(d).lines().any(|l| l == line)
1199+}
1200+
1201+/// Draw a frame, as the app does after every event: the screen's text.
1202+fn screen(app: &mut App) -> String {
1203+ let (w, h) = (120, 32);
1204+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
1205+ t.draw(|f| app.draw(f)).unwrap();
1206+ let b = t.backend().buffer().clone();
1207+ (0..h).map(|y| (0..w).map(|x| b[(x, y)].symbol()).collect::<String>() + "\n").collect()
1208+}
1209+
1210+/// The status bar, as the next frame draws it.
1211+fn status(app: &mut App) -> String {
1212+ screen(app).lines().last().unwrap_or_default().to_string()
1213+}
1214+
1215+/// Turn the wheel over what the last frame drew for `hit`, `notches`
1216+/// times down (up if negative), a frame after each.
1217+fn wheel(app: &mut App, hit: Hit, notches: i32) {
1218+ let kind = if notches > 0 { MouseEventKind::ScrollDown } else { MouseEventKind::ScrollUp };
1219+ for _ in 0..notches.abs() {
1220+ screen(app);
1221+ let (x, y) = app.hit_pos(hit).unwrap_or_else(|| panic!("nothing drawn for {:?}", hit));
1222+ app.handle_mouse(MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE });
1223+ }
1224+ screen(app);
1225+}
1226+
1227+/// Whether the last frame drew outline row `i`.
1228+fn shown(app: &App, i: usize) -> bool {
1229+ app.hit_pos(Hit::Row(i)).is_some()
1230+}
1231+
1232+#[test]
1233+fn a_key_that_changes_a_selection_out_of_view_shows_it_and_waits_for_a_second_press() {
1234+ let d = vault(&long_list());
1235+ let mut app = App::new(d.path()).unwrap();
1236+ press(&mut app, "jj");
1237+ assert_eq!(selected(&app), "task number 2");
1238+ wheel(&mut app, Hit::OutlinePane, 10);
1239+ assert!(!shown(&app, 2));
1240+ // the first x shows it and says what a second would do
1241+ press(&mut app, "x");
1242+ assert!(has_line(&d, "- [ ] task number 2"), "checked out of view");
1243+ let s = status(&mut app);
1244+ assert!(shown(&app, 2), "not brought into view");
1245+ assert!(s.contains("press x again to mark “task number 2” done"), "{}", s);
1246+ press(&mut app, "x");
1247+ assert!(has_line(&d, "- [x] task number 2"));
1248+ // d likewise: nothing trashed until it is in view
1249+ wheel(&mut app, Hit::OutlinePane, 10);
1250+ press(&mut app, "d");
1251+ assert!(has_line(&d, "- [x] task number 2"), "deleted out of view");
1252+ let s = status(&mut app);
1253+ assert!(s.contains("press d again to delete “task number 2”"), "{}", s);
1254+ press(&mut app, "d");
1255+ assert!(!root(&d).contains("task number 2\n"), "{}", root(&d));
1256+}
1257+
1258+#[test]
1259+fn a_selection_brought_into_view_sits_a_third_of_the_way_down_until_the_wheel_moves_it_again() {
1260+ let d = vault(&long_list());
1261+ let mut app = App::new(d.path()).unwrap();
1262+ select(&mut app, "task number 40");
1263+ wheel(&mut app, Hit::OutlinePane, -10);
1264+ assert!(!shown(&app, 40));
1265+ press(&mut app, "t");
1266+ assert!(has_line(&d, "- [ ] task number 40"));
1267+ let s = screen(&mut app);
1268+ let top = s.lines().position(|l| l.contains("Outline")).unwrap();
1269+ let at = s.lines().position(|l| l.contains("task number 40 ")).expect("not brought into view");
1270+ let (view, down) = (32 - 4, at - top - 1);
1271+ assert!(down >= view / 4 && down <= view * 2 / 5, "{} rows down of {}", down, view);
1272+ // the wheel takes it out of view again: the next t is a first press
1273+ wheel(&mut app, Hit::OutlinePane, -10);
1274+ press(&mut app, "t");
1275+ assert!(has_line(&d, "- [ ] task number 40"), "changed out of view");
1276+ press(&mut app, "t");
1277+ assert!(has_line(&d, "- task number 40"), "{}", root(&d));
1278+ // za, a two-key verb, too
1279+ wheel(&mut app, Hit::OutlinePane, -10);
1280+ press(&mut app, "za");
1281+ assert!(has_line(&d, "- task number 40"), "archived out of view");
1282+ let s = status(&mut app);
1283+ assert!(s.contains("press za again to archive “task number 40”"), "{}", s);
1284+ press(&mut app, "za");
1285+ assert!(root(&d).contains("# Archive"), "{}", root(&d));
1286+}
1287+
1288+#[test]
1289+fn a_key_that_changes_nothing_shows_a_selection_out_of_view_and_acts_at_once() {
1290+ let d = vault(&long_list());
1291+ let mut app = App::new(d.path()).unwrap();
1292+ press(&mut app, "jj");
1293+ wheel(&mut app, Hit::OutlinePane, 10);
1294+ press(&mut app, "m");
1295+ screen(&mut app);
1296+ assert!(app.hit_pos(Hit::MenuItem(0)).is_some(), "no node menu");
1297+ assert!(shown(&app, 2));
1298+ app.handle_key(key(KeyCode::Esc));
1299+ wheel(&mut app, Hit::OutlinePane, 10);
1300+ press(&mut app, "e");
1301+ assert_eq!(app.mode_pub(), "edit");
1302+ screen(&mut app);
1303+ assert!(shown(&app, 2));
1304+}
1305+
1306+#[test]
1307+fn the_reading_pane_shows_its_cursor_line_before_changing_it() {
1308+ let prose: String = (1..=60).map(|i| format!("line {} of the notes\n", i)).collect();
1309+ let d = vault(&format!("# Notes\n\n{}\n- [ ] buried task\n", prose));
1310+ let mut app = App::new(d.path()).unwrap();
1311+ app.show_reading = true;
1312+ screen(&mut app);
1313+ app.handle_key(key(KeyCode::Tab));
1314+ press(&mut app, "G");
1315+ let task = app.read_cursor_pub();
1316+ assert!(app.reading_doc_pub().lines[task].contains("buried task"));
1317+ wheel(&mut app, Hit::ReadingPane, -10);
1318+ assert!(app.hit_pos(Hit::DocLine(task)).is_none());
1319+ press(&mut app, "x");
1320+ assert!(has_line(&d, "- [ ] buried task"), "checked out of view");
1321+ let s = status(&mut app);
1322+ assert!(app.hit_pos(Hit::DocLine(task)).is_some(), "not brought into view");
1323+ assert!(s.contains("press x again to mark “buried task” done"), "{}", s);
1324+ press(&mut app, "x");
1325+ assert!(has_line(&d, "- [x] buried task"));
1326+ // Enter on a task toggles it: the same
1327+ wheel(&mut app, Hit::ReadingPane, -10);
1328+ app.handle_key(key(KeyCode::Enter));
1329+ assert!(has_line(&d, "- [x] buried task"), "reopened out of view");
1330+ let s = status(&mut app);
1331+ assert!(s.contains("press Enter again to reopen “buried task”"), "{}", s);
1332+ app.handle_key(key(KeyCode::Enter));
1333+ assert!(has_line(&d, "- [ ] buried task"));
1334+ assert!(!status(&mut app).contains("again"), "the words outlived the verb");
1335+}
1336+
1337+#[test]
1338+fn a_held_verb_s_words_go_once_it_acts_or_the_selection_moves() {
1339+ let d = vault(&long_list());
1340+ let mut app = App::new(d.path()).unwrap();
1341+ press(&mut app, "jj");
1342+ wheel(&mut app, Hit::OutlinePane, 10);
1343+ // J says nothing when it moves a node: the words must not stay
1344+ press(&mut app, "J");
1345+ let s = status(&mut app);
1346+ assert!(s.contains("press J again to move “task number 2” down"), "{}", s);
1347+ press(&mut app, "J");
1348+ assert!(root(&d).contains("- [ ] task number 3\n- [ ] task number 2\n"), "{}", root(&d));
1349+ let s = status(&mut app);
1350+ assert!(!s.contains("again"), "{}", s);
1351+ // a click selects another node: the words named the one before
1352+ wheel(&mut app, Hit::OutlinePane, 10);
1353+ press(&mut app, "d");
1354+ assert!(status(&mut app).contains("press d again"));
1355+ let (x, y) = app.hit_pos(Hit::Row(10)).unwrap();
1356+ for kind in [MouseEventKind::Down(MouseButton::Left), MouseEventKind::Up(MouseButton::Left)] {
1357+ app.handle_mouse(MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE });
1358+ }
1359+ let s = status(&mut app);
1360+ assert!(!s.contains("again"), "{}", s);
1361+}
1362+
1363+#[test]
1364+fn the_pointer_acts_on_a_selection_out_of_view_at_once() {
1365+ let d = vault(&long_list());
1366+ let mut app = App::new(d.path()).unwrap();
1367+ app.show_reading = true;
1368+ press(&mut app, "jj");
1369+ wheel(&mut app, Hit::OutlinePane, 10);
1370+ assert!(!shown(&app, 2));
1371+ // the reading pane's ⋯ is the selected node's menu
1372+ click_button(&mut app, Action::NodeMenu);
1373+ screen(&mut app);
1374+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(Action::Delete))).unwrap();
1375+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
1376+ assert!(!root(&d).contains("task number 2\n"), "{}", root(&d));
1377+}
1378+
1379+/// The status bar at 80×24, as the next frame draws it.
1380+fn status80(app: &mut App) -> String {
1381+ let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
1382+ t.draw(|f| app.draw(f)).unwrap();
1383+ let b = t.backend().buffer().clone();
1384+ (0..80).map(|x| b[(x, 23)].symbol()).collect()
1385+}
1386+
1387+/// Turn the wheel down over the outline at 80×24, `notches` times.
1388+fn wheel80(app: &mut App, notches: usize) {
1389+ for _ in 0..notches {
1390+ status80(app);
1391+ let (x, y) = app.hit_pos(Hit::OutlinePane).unwrap();
1392+ app.handle_mouse(MouseEvent { kind: MouseEventKind::ScrollDown, column: x, row: y, modifiers: KeyModifiers::NONE });
1393+ }
1394+ status80(app);
1395+}
1396+
1397+#[test]
1398+fn a_held_verb_s_words_keep_the_key_and_what_it_does_at_80_columns() {
1399+ // a title too long for the bar gives way, not the words after it
1400+ let title = "Replace the flaky switch in the closet before Friday";
1401+ let d = vault(&long_list().replace("task number 2\n", &format!("{}\n", title)));
1402+ let mut app = App::new(d.path()).unwrap();
1403+ select(&mut app, title);
1404+ wheel80(&mut app, 10);
1405+ press(&mut app, "x");
1406+ let s = status80(&mut app);
1407+ assert!(s.starts_with(" press x again to mark “Replace the flaky"), "{}", s);
1408+ assert!(s.contains("…” done "), "{}", s);
1409+ press(&mut app, "x");
1410+ assert!(has_line(&d, &format!("- [x] {}", title)), "{}", root(&d));
1411+ // with the ⚠ count and done hidden on the right, a short title still fits
1412+ let text = format!("# Networking\n\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n\n{}", long_list());
1413+ let d = vault(&text);
1414+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text.replace("[ ] task number 80", "[x] task number 80")).unwrap();
1415+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1416+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
1417+ drop(v);
1418+ let mut app = App::new(d.path()).unwrap();
1419+ select(&mut app, "rack");
1420+ press(&mut app, "zd");
1421+ wheel80(&mut app, 10);
1422+ press(&mut app, "d");
1423+ let s = status80(&mut app);
1424+ assert!(s.contains(" ⚠ 1 conflict done hidden "), "{}", s);
1425+ assert!(s.starts_with(" press d again to delete “rack” "), "{}", s);
1426+ press(&mut app, "d");
1427+ assert!(!root(&d).contains("rack"), "{}", root(&d));
1428+}
1429+
1430+// ------------------------------------------------------------ unsaved text
1431+
1432+/// The trash entries for editor text on `title`'s node that fold could
1433+/// not save (§11.5), `<timestamp>-unsaved-<title>.md`, as the returned
1434+/// closure finds them: those made after this call, since the trash
1435+/// outlives a test run.
1436+fn kept(title: &str) -> impl Fn() -> Vec<std::path::PathBuf> {
1437+ let suffix = format!("-unsaved-{}.md", title);
1438+ let entries = move || {
1439+ let mut out: Vec<_> = std::fs::read_dir(fold_core::vault::trash_dir())
1440+ .map(|rd| rd.filter_map(|e| e.ok()).map(|e| e.path()).collect())
1441+ .unwrap_or_default();
1442+ out.retain(|p: &std::path::PathBuf| p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(&suffix)));
1443+ out
1444+ };
1445+ let before = entries();
1446+ move || entries().into_iter().filter(|p| !before.contains(p)).collect()
1447+}
1448+
1449+/// The editor open on the vault's first node, `typed` typed at the end of
1450+/// its title line.
1451+fn typing(d: &tempfile::TempDir, typed: &str) -> App {
1452+ let mut app = App::new(d.path()).unwrap();
1453+ app.set_edit_keys(EditKeys::Normal);
1454+ press(&mut app, "e");
1455+ app.handle_key(key(KeyCode::End));
1456+ press(&mut app, typed);
1457+ app
1458+}
1459+
1460+#[test]
1461+fn reverting_text_a_save_was_refused_for_keeps_a_copy_in_the_trash() {
1462+ let d = vault("# Rotation schedule\n\nkeep 24\n");
1463+ let kept = kept("rotation-schedule");
1464+ let mut app = typing(&d, " hourly");
1465+ // another program changes the node: the save is refused, and the
1466+ // editor stays open with the text
1467+ std::fs::write(d.path().join("root.md"), "# Rotation schedule\n\nkeep 48\n").unwrap();
1468+ app.handle_key(key(KeyCode::Esc));
1469+ assert_eq!(app.mode_pub(), "edit");
1470+ // :q! leaves it, and the text goes to the trash first
1471+ app.handle_key(ctrl('e'));
1472+ press(&mut app, "q!");
1473+ app.handle_key(key(KeyCode::Enter));
1474+ assert_eq!(app.mode_pub(), "normal");
1475+ assert_eq!(root(&d), "# Rotation schedule\n\nkeep 48\n");
1476+ let copies = kept();
1477+ assert_eq!(copies.len(), 1, "{:?}", copies);
1478+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Rotation schedule hourly\n\nkeep 24\n");
1479+ // the status line says where
1480+ let name = copies[0].file_name().unwrap().to_string_lossy().to_string();
1481+ let s = status(&mut app);
1482+ assert!(s.contains(&format!("changes discarded; a copy is in the trash: {}", name)), "{}", s);
1483+}
1484+
1485+#[test]
1486+fn reverting_text_no_save_refused_keeps_no_copy() {
1487+ let d = vault("# Retention window\n\nkeep 24\n");
1488+ let kept = kept("retention-window");
1489+ let mut app = typing(&d, " days");
1490+ app.run_action(Action::EditRevert);
1491+ assert_eq!(app.mode_pub(), "normal");
1492+ assert_eq!(root(&d), "# Retention window\n\nkeep 24\n");
1493+ assert!(kept().is_empty());
1494+ let s = status(&mut app);
1495+ assert!(s.contains("changes discarded") && !s.contains("trash"), "{}", s);
1496+}
1497+
1498+#[test]
1499+fn ending_fold_while_editing_saves_the_editor() {
1500+ // a signal, an error or a crash: the editor is saved as on a quit
1501+ let d = vault("# Offsite copy\n\nnightly\n");
1502+ let kept = kept("offsite-copy");
1503+ let mut app = typing(&d, " to B2");
1504+ assert_eq!(app.keep_unsaved(), None);
1505+ assert_eq!(root(&d), "# Offsite copy to B2\n\nnightly\n");
1506+ assert!(kept().is_empty());
1507+ // nothing unsaved, nothing to keep
1508+ assert_eq!(app.keep_unsaved(), None);
1509+}
1510+
1511+#[test]
1512+fn ending_fold_while_editing_deletes_a_cut_block_the_autosave_left_in_transit() {
1513+ // §5.2: a block cut and not pasted back is deleted on any end of the
1514+ // app, even once the autosave has left nothing unsaved
1515+ let d = vault("# A\n\n- one\n- task\n- two\n");
1516+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1517+ let t = v.find_by_path(&["A".into(), "task".into()]).unwrap();
1518+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1519+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1520+ drop(v);
1521+ let kept = kept("a");
1522+ let mut app = start(&d);
1523+ app.set_edit_keys(EditKeys::Normal);
1524+ app.handle_key(key(KeyCode::Char('e')));
1525+ for _ in 0..3 {
1526+ app.handle_key(key(KeyCode::Down));
1527+ }
1528+ app.handle_key(ctrl('k'));
1529+ idle(&mut app, 1000);
1530+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
1531+ assert!(block.exists());
1532+ assert_eq!(app.keep_unsaved(), None);
1533+ assert!(!block.exists(), "the cut block outlived the app");
1534+ assert_eq!(root(&d), "# A\n\n- one\n- two\n");
1535+ assert!(kept().is_empty());
1536+}
1537+
1538+#[test]
1539+fn ending_fold_with_text_a_save_was_refused_for_keeps_it_in_the_trash() {
1540+ let d = vault("# Scrub cadence\n\n- monthly\n- [ ] scrub now\n");
1541+ let kept = kept("scrub-cadence");
1542+ let mut app = typing(&d, " weekly");
1543+ std::fs::write(d.path().join("root.md"), "# Scrub cadence\n\n- monthly, from the phone\n- [ ] scrub now\n").unwrap();
1544+ let words = app.keep_unsaved().expect("nothing kept");
1545+ let copies = kept();
1546+ assert_eq!(copies.len(), 1, "{:?}", copies);
1547+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1548+ // the editor's whole text, as it was
1549+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Scrub cadence weekly\n\n- monthly\n- [ ] scrub now\n");
1550+ // and the other program's change stays
1551+ assert_eq!(root(&d), "# Scrub cadence\n\n- monthly, from the phone\n- [ ] scrub now\n");
1552+}
1553+
1554+#[test]
1555+fn a_panic_in_the_autosave_keeps_the_editor_s_text_in_the_trash() {
1556+ // a bug that panics where the editor saves (§10.6): `run` catches it,
1557+ // and the text the save was writing is kept as on any other end
1558+ let d = vault("# Snapshot rotation\n\nkeep 24\n");
1559+ let kept = kept("snapshot-rotation");
1560+ let mut app = typing(&d, " hourly");
1561+ app.panic_in_saves();
1562+ std::thread::sleep(Duration::from_millis(800));
1563+ let tick = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.tick()));
1564+ assert!(tick.is_err(), "the autosave did not panic");
1565+ let words = app.keep_unsaved().expect("nothing kept");
1566+ let copies = kept();
1567+ assert_eq!(copies.len(), 1, "{:?}", copies);
1568+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1569+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Snapshot rotation hourly\n\nkeep 24\n");
1570+ assert_eq!(root(&d), "# Snapshot rotation\n\nkeep 24\n");
1571+}
1572+
1573+#[test]
1574+fn a_panic_in_the_save_on_leaving_a_block_keeps_the_editor_s_text_in_the_trash() {
1575+ // moving to another block saves the one left (§10.6): the same
1576+ let d = vault("# Scrub plan\n\n- one\n- task\n- two\n");
1577+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
1578+ let t = v.find_by_path(&["Scrub plan".into(), "task".into()]).unwrap();
1579+ let id = fold_core::ops::make_block(&mut v, t).unwrap();
1580+ let block = v.dir.join(&v.tree.files[v.tree.block_by_id(&id).unwrap().0].path);
1581+ let before = std::fs::read_to_string(&block).unwrap();
1582+ drop(v);
1583+ let kept = kept("scrub-plan");
1584+ let mut app = App::new(d.path()).unwrap();
1585+ app.set_edit_keys(EditKeys::Normal);
1586+ press(&mut app, "e");
1587+ for _ in 0..3 {
1588+ app.handle_key(key(KeyCode::Down));
1589+ }
1590+ app.handle_key(key(KeyCode::End));
1591+ press(&mut app, " weekly");
1592+ app.panic_in_saves();
1593+ let up = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| app.handle_key(key(KeyCode::Up))));
1594+ assert!(up.is_err(), "leaving the block did not save it");
1595+ let words = app.keep_unsaved().expect("nothing kept");
1596+ let copies = kept();
1597+ assert_eq!(copies.len(), 1, "{:?}", copies);
1598+ assert_eq!(words, format!("unsaved text kept in {}", copies[0].display()));
1599+ assert_eq!(std::fs::read_to_string(&copies[0]).unwrap(), "# Scrub plan\n\n- one\n- task weekly\n- two\n");
1600+ assert_eq!(std::fs::read_to_string(&block).unwrap(), before);
1601+}
1602+
1603+/// Run the test named `test` again in a process of its own, with a trash
1604+/// nothing can be written to: a plain file where its directory would be
1605+/// (§11.5). The trash is the process's, where `$XDG_STATE_HOME` says, so
1606+/// the other tests keep theirs. True in that process, where the test goes
1607+/// on; false in this one, once it has passed there.
1608+fn with_trash_unwritable(test: &str) -> bool {
1609+ if std::env::var_os("FOLD_TEST_TRASH_UNWRITABLE").is_some() {
1610+ return true;
1611+ }
1612+ let state = tempfile::tempdir().unwrap();
1613+ std::fs::create_dir(state.path().join("fold")).unwrap();
1614+ std::fs::write(state.path().join("fold").join("trash"), "").unwrap();
1615+ let out = std::process::Command::new(std::env::current_exe().unwrap())
1616+ .args([test, "--exact", "--nocapture"])
1617+ .env("XDG_STATE_HOME", state.path())
1618+ .env("FOLD_TEST_TRASH_UNWRITABLE", "1")
1619+ .output()
1620+ .unwrap();
1621+ let said = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
1622+ assert!(out.status.success() && said.contains("1 passed"), "{}", said);
1623+ false
1624+}
1625+
1626+#[test]
1627+fn reverting_text_the_trash_cannot_take_drops_it_when_asked_again() {
1628+ if !with_trash_unwritable("reverting_text_the_trash_cannot_take_drops_it_when_asked_again") {
1629+ return;
1630+ }
1631+ let d = vault("# Rotation schedule\n\nkeep 24\n");
1632+ let mut app = typing(&d, " hourly");
1633+ std::fs::write(d.path().join("root.md"), "# Rotation schedule\n\nkeep 48\n").unwrap();
1634+ app.handle_key(key(KeyCode::Esc));
1635+ assert_eq!(app.mode_pub(), "edit");
1636+ // :q! finds no trash to copy the text to: it drops nothing, and says
1637+ // what does
1638+ let revert = |app: &mut App| {
1639+ app.handle_key(ctrl('e'));
1640+ press(app, "q!");
1641+ app.handle_key(key(KeyCode::Enter));
1642+ };
1643+ revert(&mut app);
1644+ assert_eq!(app.mode_pub(), "edit");
1645+ let s = status_line(&mut app);
1646+ assert!(s.contains("can't copy to the trash; Revert (:q!) again drops the changes — File exists (os error 17)"), "{}", s);
1647+ // text typed since is new to it: that is asked about again
1648+ press(&mut app, " and");
1649+ app.run_action(Action::EditRevert);
1650+ assert_eq!(app.mode_pub(), "edit");
1651+ assert!(status_line(&mut app).contains("can't copy to the trash"));
1652+ // asked again, it drops them
1653+ revert(&mut app);
1654+ assert_eq!(app.mode_pub(), "normal");
1655+ assert!(status_line(&mut app).contains("changes discarded; no copy kept"));
1656+ assert_eq!(root(&d), "# Rotation schedule\n\nkeep 48\n");
1657+ // and a quit ends fold
1658+ app.run_action(Action::Quit);
1659+ assert!(app.quit_requested() && app.mode_pub() == "normal");
1660+}
1661+
1662+// ------------------------------------------------------------ sync conflicts
1663+
1664+/// Conflict block ids whose files list first and last: a merge names its
1665+/// blocks by fresh ids, and pairs are found in their blocks' files' order,
1666+/// where the view lists them in the outline's (§12.5).
1667+const FIRST: &str = "bacbec-bacbec-bacbec-bacbec";
1668+const LAST: &str = "worzod-worzod-worzod-worzod";
1669+
1670+/// A small homelab vault. With `old`, a pair left from an earlier sync
1671+/// conflict (§12.5) on the flaky switch, its conflict block under that id.
1672+fn homelab(old: Option<&str>) -> tempfile::TempDir {
1673+ let embed = old.map(|id| format!("![[{}]]\n", id)).unwrap_or_default();
1674+ let d = vault(&format!(
1675+ "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n## Networking\n\n- [ ] Replace the flaky switch\n{}- [ ] Label the cables\n",
1676+ embed
1677+ ));
1678+ if let Some(id) = old {
1679+ let prefix = id.split('-').next().unwrap();
1680+ std::fs::write(
1681+ d.path().join(format!("{}~replace-the-flaky-switch.md", prefix)),
1682+ format!("---\nid: {}\nconflict: \"PHONE 20260926-090000\"\n---\n\n- [x] Replace the flaky switch\n", id),
1683+ )
1684+ .unwrap();
1685+ }
1686+ d
1687+}
1688+
1689+/// Syncthing's copy of root.md from a phone that changed NAS's text and
1690+/// checked the cables: two pairs once merged.
1691+fn phone_copy(d: &tempfile::TempDir) {
1692+ std::fs::write(
1693+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
1694+ "# Homelab\n\n## NAS\n\nMirrored pairs, no raidz.\n\n## Networking\n\n- [ ] Replace the flaky switch\n- [x] Label the cables\n",
1695+ )
1696+ .unwrap();
1697+}
1698+
1699+fn pairs(app: &mut App) -> usize {
1700+ fold_core::merge::conflict_pairs(app.vault_mut()).len()
1701+}
1702+
1703+/// Whether the status bar's ⚠ count is lit, as it is while pairs that came
1704+/// in as you worked wait to be seen.
1705+fn lit(app: &mut App) -> bool {
1706+ let (w, h) = (120, 32);
1707+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
1708+ t.draw(|f| app.draw(f)).unwrap();
1709+ let b = t.backend().buffer();
1710+ // the count is on the right; the message on the left may name it too
1711+ let badge = (0..w).rev().map(|x| &b[(x, h - 1)]).find(|c| c.symbol() == "⚠").expect("no ⚠ in the status bar");
1712+ badge.bg != b[(0, h - 1)].bg
1713+}
1714+
1715+#[test]
1716+fn a_sync_conflict_while_typing_leaves_the_editor_open_and_the_keys_in_it() {
1717+ let d = homelab(None);
1718+ let mut app = start(&d);
1719+ app.set_edit_keys(EditKeys::Normal);
1720+ select(&mut app, "NAS");
1721+ press(&mut app, "e");
1722+ app.handle_key(key(KeyCode::Down));
1723+ app.handle_key(key(KeyCode::Down));
1724+ app.handle_key(key(KeyCode::End));
1725+ // a key every 120 ms, the main loop running between them; the phone's
1726+ // copy lands mid-word
1727+ let type_slowly = |app: &mut App, text: &str| {
1728+ for c in text.chars() {
1729+ app.handle_key(key(KeyCode::Char(c)));
1730+ std::thread::sleep(Duration::from_millis(120));
1731+ app.tick();
1732+ }
1733+ };
1734+ type_slowly(&mut app, " bot");
1735+ phone_copy(&d);
1736+ type_slowly(&mut app, "tom note, both boot targets");
1737+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was not taken in");
1738+ // o, t, b and e went on typing: no pair was picked unseen
1739+ assert_eq!(app.mode_pub(), "edit");
1740+ assert_eq!(pairs(&mut app), 2);
1741+ let s = status_line(&mut app);
1742+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve"), "{}", s);
1743+ assert!(lit(&mut app));
1744+ app.handle_key(key(KeyCode::Esc));
1745+ assert!(has_line(&d, "Mirrored pairs. bottom note, both boot targets"), "{}", root(&d));
1746+ // the ⚠ opens the view on the first of them, and it takes o at once
1747+ assert!(lit(&mut app));
1748+ click_button(&mut app, Action::ResolveConflicts);
1749+ assert_eq!(app.mode_pub(), "conflict");
1750+ assert!(!lit(&mut app));
1751+ assert!(screen(&mut app).contains("Mirrored pairs, no raidz."));
1752+ press(&mut app, "o");
1753+ assert_eq!(pairs(&mut app), 1);
1754+}
1755+
1756+#[test]
1757+fn pairs_that_came_in_while_busy_open_once_the_outline_is_idle() {
1758+ let d = homelab(Some(FIRST));
1759+ let mut app = App::new(d.path()).unwrap();
1760+ assert_eq!(pairs(&mut app), 1);
1761+ press(&mut app, "j");
1762+ phone_copy(&d);
1763+ app.reload_external();
1764+ // a key a moment ago: the outline stays
1765+ assert_eq!(app.mode_pub(), "normal");
1766+ let s = status(&mut app);
1767+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve"), "{}", s);
1768+ assert!(lit(&mut app));
1769+ assert_eq!(idle(&mut app, 1500), 0);
1770+ assert_eq!(app.mode_pub(), "normal");
1771+ // two seconds without a key: the view opens on NAS, not on the pair
1772+ // left from before, whose block's file is listed first
1773+ idle(&mut app, 1000);
1774+ assert_eq!(app.mode_pub(), "conflict");
1775+ let s = screen(&mut app);
1776+ assert!(s.contains("Mirrored pairs, no raidz.") && !s.contains("flaky"), "{}", s);
1777+ assert!(!lit(&mut app));
1778+ // closed, it stays closed
1779+ app.handle_key(key(KeyCode::Esc));
1780+ std::thread::sleep(Duration::from_millis(2100));
1781+ app.tick();
1782+ assert_eq!(app.mode_pub(), "normal");
1783+}
1784+
1785+#[test]
1786+fn the_conflict_view_lists_its_pairs_in_outline_order_from_the_one_it_opens_on() {
1787+ // a pair left from before on the flaky switch, its block's file listed
1788+ // first; the phone's copy raises NAS, above it, and the cables, below
1789+ let d = homelab(Some(FIRST));
1790+ let mut app = App::new(d.path()).unwrap();
1791+ press(&mut app, "j");
1792+ phone_copy(&d);
1793+ app.reload_external();
1794+ assert_eq!(pairs(&mut app), 3);
1795+ // the ⚠ opens the view on NAS, the first new pair, and counts it first
1796+ click_button(&mut app, Action::ResolveConflicts);
1797+ assert_eq!(app.mode_pub(), "conflict");
1798+ let s = screen(&mut app);
1799+ assert!(s.contains("Conflict 1 of 3") && s.contains("no raidz"), "{}", s);
1800+ // n goes down the outline to the last pair, N back up
1801+ press(&mut app, "n");
1802+ let s = screen(&mut app);
1803+ assert!(s.contains("Conflict 2 of 3") && s.contains("flaky"), "{}", s);
1804+ press(&mut app, "n");
1805+ let s = screen(&mut app);
1806+ assert!(s.contains("Conflict 3 of 3") && s.contains("Label the cables"), "{}", s);
1807+ press(&mut app, "NN");
1808+ let s = screen(&mut app);
1809+ assert!(s.contains("Conflict 1 of 3") && s.contains("no raidz"), "{}", s);
1810+}
1811+
1812+#[test]
1813+fn where_the_bar_is_short_the_news_of_a_sync_conflict_takes_room_from_the_right_first() {
1814+ // 80×24, a key a moment ago: the lit ⚠ count beside it comes down to
1815+ // its number, and the news is whole
1816+ let d = homelab(None);
1817+ let mut app = App::new(d.path()).unwrap();
1818+ press(&mut app, "j");
1819+ phone_copy(&d);
1820+ app.reload_external();
1821+ assert_eq!(app.mode_pub(), "normal");
1822+ let s = status80(&mut app);
1823+ assert!(s.starts_with(" sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1824+ assert!(s.ends_with(" ⚠ 2 root.md ✓ saved "), "{}", s);
1825+ // wider, the count says what it counts
1826+ let s = status(&mut app);
1827+ assert!(s.contains("sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1828+ assert!(s.contains(" ⚠ 2 conflicts "), "{}", s);
1829+ // typing on NAS, with less room still: the file goes too
1830+ let d = homelab(None);
1831+ let mut app = App::new(d.path()).unwrap();
1832+ select(&mut app, "NAS");
1833+ press(&mut app, "e");
1834+ phone_copy(&d);
1835+ app.reload_external();
1836+ assert_eq!(app.mode_pub(), "edit");
1837+ let s = status80(&mut app);
1838+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1839+ assert!(s.ends_with(" ⚠ 2 ✓ saved "), "{}", s);
1840+}
1841+
1842+#[test]
1843+fn news_of_a_sync_conflict_while_typing_says_what_to_do_and_no_more() {
1844+ // the typing on NAS is saved before the merge, as the save state on the
1845+ // right says; the news keeps to the pairs and what to do
1846+ let d = homelab(None);
1847+ let mut app = App::new(d.path()).unwrap();
1848+ app.set_edit_keys(EditKeys::Normal);
1849+ select(&mut app, "NAS");
1850+ press(&mut app, "e");
1851+ app.handle_key(key(KeyCode::Down));
1852+ app.handle_key(key(KeyCode::Down));
1853+ app.handle_key(key(KeyCode::End));
1854+ press(&mut app, " typed");
1855+ phone_copy(&d);
1856+ app.reload_external();
1857+ assert_eq!(app.mode_pub(), "edit");
1858+ assert!(has_line(&d, "Mirrored pairs. typed"), "{}", root(&d));
1859+ // 80×24: the right side gives way first, as with nothing typed
1860+ let s = status80(&mut app);
1861+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1862+ assert!(s.ends_with(" ⚠ 2 ✓ saved "), "{}", s);
1863+ // 120 columns: whole, the name too
1864+ let s = status(&mut app);
1865+ assert!(s.starts_with(" EDIT sync conflicts in “NAS” and 1 more: click ⚠ to resolve "), "{}", s);
1866+ assert!(!s.contains("typing"), "{}", s);
1867+}
1868+
1869+#[test]
1870+fn at_80_columns_news_of_a_sync_conflict_in_the_editor_keeps_the_node_and_what_to_do() {
1871+ // 80×24 while editing: the right side gives way before the news does,
1872+ // the ⚠ count down to its number
1873+ let d = vault(SAMPLE);
1874+ let mut app = App::new(d.path()).unwrap();
1875+ select(&mut app, "NAS");
1876+ press(&mut app, "e");
1877+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE77.md"), SAMPLE.replace("no raidz.", "no raidz, one spare.")).unwrap();
1878+ app.reload_external();
1879+ assert_eq!(app.mode_pub(), "edit");
1880+ assert_eq!(pairs(&mut app), 1);
1881+ let s = status80(&mut app);
1882+ assert!(s.starts_with(" EDIT sync conflict in “NAS”: click ⚠ to resolve "), "{}", s);
1883+ assert!(s.ends_with(" ⚠ 1 root.md ✓ saved "), "{}", s);
1884+ // with room, the count says what it counts
1885+ assert!(status(&mut app).contains(" ⚠ 1 conflict root.md ✓ saved "), "{}", status(&mut app));
1886+}
1887+
1888+#[test]
1889+fn at_80_columns_news_of_a_sync_conflict_in_a_block_file_keeps_the_node_and_what_to_do() {
1890+ // the selection in a block file, whose name is long: the file gives way
1891+ // too, in the editor and in the outline after it
1892+ let d = vault(SAMPLE);
1893+ let mut app = App::new(d.path()).unwrap();
1894+ select(&mut app, "Project Atlas");
1895+ press(&mut app, "s");
1896+ let block = std::fs::read_dir(d.path())
1897+ .unwrap()
1898+ .map(|e| e.unwrap().path())
1899+ .find(|p| p.to_string_lossy().ends_with("~project-atlas.md"))
1900+ .unwrap();
1901+ let file = block.file_name().unwrap().to_string_lossy().into_owned();
1902+ let text = std::fs::read_to_string(&block).unwrap();
1903+ press(&mut app, "e");
1904+ std::fs::write(
1905+ d.path().join(file.replace(".md", ".sync-conflict-20260927-100000-PHONE77.md")),
1906+ text.replace("[ ] Draft", "[x] Draft"),
1907+ )
1908+ .unwrap();
1909+ app.reload_external();
1910+ assert_eq!(app.mode_pub(), "edit");
1911+ assert_eq!(pairs(&mut app), 1);
1912+ let s = status80(&mut app);
1913+ assert!(s.starts_with(" EDIT sync conflict in “Draft the RFC”: click ⚠ to resolve "), "{}", s);
1914+ assert!(s.ends_with(" ⚠ 1 ✓ saved "), "{}", s);
1915+ app.handle_key(key(KeyCode::Esc));
1916+ assert_eq!(app.mode_pub(), "normal");
1917+ let s = status80(&mut app);
1918+ assert!(s.starts_with(" sync conflict in “Draft the RFC”: click ⚠ to resolve "), "{}", s);
1919+ assert!(!s.contains(&file), "{}", s);
1920+ // with room, the file is there
1921+ assert!(status(&mut app).contains(&format!(" ⚠ 1 conflict {} ✓ saved ", file)), "{}", status(&mut app));
1922+}
1923+
1924+#[test]
1925+fn a_view_that_opens_on_its_own_takes_no_side_for_half_a_second() {
1926+ let d = homelab(None);
1927+ let mut app = App::new(d.path()).unwrap();
1928+ phone_copy(&d);
1929+ // idle in the outline: the view opens at once
1930+ app.reload_external();
1931+ assert_eq!(app.mode_pub(), "conflict");
1932+ let s = status(&mut app);
1933+ assert!(s.contains("sync conflicts in “NAS” and 1 more"), "{}", s);
1934+ // keys meant for the outline pick nothing, nor edit
1935+ press(&mut app, "otbe");
1936+ assert_eq!(app.mode_pub(), "conflict");
1937+ assert_eq!(pairs(&mut app), 2);
1938+ std::thread::sleep(Duration::from_millis(550));
1939+ press(&mut app, "t");
1940+ assert_eq!(pairs(&mut app), 1);
1941+ assert!(has_line(&d, "Mirrored pairs, no raidz."), "{}", root(&d));
1942+}
1943+
1944+#[test]
1945+fn resolving_a_pair_names_its_node_and_u_in_the_view_undoes_it() {
1946+ let d = homelab(None);
1947+ let mut app = App::new(d.path()).unwrap();
1948+ phone_copy(&d);
1949+ fold_core::merge::merge_sync_conflicts(app.vault_mut(), false).unwrap();
1950+ app.enter_conflict_view();
1951+ let shown = |app: &mut App| if screen(app).contains("Mirrored pairs, no raidz.") { "NAS" } else { "Label the cables" };
1952+ let first = shown(&mut app);
1953+ press(&mut app, "t");
1954+ assert!(status(&mut app).contains(&format!("kept theirs for “{}” · u undoes", first)), "{}", status(&mut app));
1955+ // u, in the view, puts the pair back and shows it
1956+ press(&mut app, "u");
1957+ assert_eq!(pairs(&mut app), 2);
1958+ assert_eq!(app.mode_pub(), "conflict");
1959+ assert_eq!(shown(&mut app), first);
1960+ assert!(status(&mut app).contains(&format!("undone: keep theirs for “{}”", first)), "{}", status(&mut app));
1961+ // the buttons say the same
1962+ click_button(&mut app, Action::ConflictOurs);
1963+ assert!(status(&mut app).contains(&format!("kept ours for “{}” · u undoes", first)), "{}", status(&mut app));
1964+ let second = shown(&mut app);
1965+ assert_ne!(second, first);
1966+ press(&mut app, "b");
1967+ assert!(status(&mut app).contains(&format!("kept both for “{}” · u undoes", second)), "{}", status(&mut app));
1968+ assert_eq!(pairs(&mut app), 0);
1969+}
1970+
1971+#[test]
1972+fn pairs_that_come_in_while_the_view_is_open_leave_it_on_the_pair_it_shows() {
1973+ let d = homelab(Some(LAST));
1974+ let mut app = App::new(d.path()).unwrap();
1975+ app.enter_conflict_view();
1976+ assert!(screen(&mut app).contains("flaky"));
1977+ phone_copy(&d);
1978+ app.reload_external();
1979+ // listed after NAS now, the pair on screen stays on screen
1980+ assert_eq!(app.mode_pub(), "conflict");
1981+ assert_eq!(pairs(&mut app), 3);
1982+ let s = screen(&mut app);
1983+ assert!(s.contains("Conflict 2 of 3") && s.contains("flaky"), "{}", s);
1984+ press(&mut app, "o");
1985+ assert!(!root(&d).contains(LAST), "{}", root(&d));
1986+ assert_eq!(pairs(&mut app), 2);
1987+}
1988+
1989+/// A task of its own in the homelab's Networking list, as a block.
1990+fn milk_block(d: &tempfile::TempDir, file: &str, task: &str) {
1991+ std::fs::write(d.path().join(file), format!("---\nid: {}\n---\n\n- {} Buy milk\n", LAST, task)).unwrap();
1992+}
1993+
1994+#[test]
1995+fn a_block_that_comes_in_with_its_copy_is_merged_with_it() {
1996+ let d = homelab(None);
1997+ let mut app = start(&d);
1998+ idle(&mut app, 300);
1999+ // one sync brings a block from the laptop, its embed, and the phone's
2000+ // copy of it
2001+ milk_block(&d, "worzod~buy-milk.md", "[ ]");
2002+ milk_block(&d, "worzod~buy-milk.sync-conflict-20260927-100000-PHONE.md", "[x]");
2003+ std::fs::write(d.path().join("root.md"), format!("{}![[{}]]\n", root(&d), LAST)).unwrap();
2004+ until_reload(&mut app);
2005+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copy was left alone");
2006+ assert_eq!(pairs(&mut app), 1);
2007+ assert_eq!(app.mode_pub(), "conflict");
2008+}
2009+
2010+#[test]
2011+fn copies_a_merge_failed_on_are_merged_with_the_next_change() {
2012+ let d = homelab(None);
2013+ std::fs::write(d.path().join("root.md"), format!("{}![[{}]]\n", root(&d), LAST)).unwrap();
2014+ milk_block(&d, "worzod~buy-milk.md", "[ ]");
2015+ let mut app = start(&d);
2016+ idle(&mut app, 300);
2017+ // root.md cannot be written for now, as when the trash cannot be: the
2018+ // merge stops at its copy, before the one after it
2019+ let tmp = d.path().join(".root.md.fold-tmp");
2020+ std::fs::create_dir(&tmp).unwrap();
2021+ phone_copy(&d);
2022+ milk_block(&d, "worzod~buy-milk.sync-conflict-20260927-100000-PHONE.md", "[x]");
2023+ until_reload(&mut app);
2024+ assert!(status(&mut app).contains("merge error"), "{}", status(&mut app));
2025+ assert_eq!(app.vault_conflict_files().unwrap().len(), 2);
2026+ // it can be again: the next change from outside takes both in
2027+ std::fs::remove_dir(&tmp).unwrap();
2028+ std::fs::write(d.path().join("root.md"), format!("{}- [ ] from laptop\n", root(&d))).unwrap();
2029+ until_reload(&mut app);
2030+ assert!(app.vault_conflict_files().unwrap().is_empty(), "the copies were not merged");
2031+ assert!(has_line(&d, "- [ ] from laptop"), "{}", root(&d));
2032+ assert_eq!(pairs(&mut app), 3);
2033+}
2034+
2035+// ------------------------------------------------------------ conflict copies
2036+
2037+/// A homelab vault merged with a phone's copy of it that changed NAS's
2038+/// text and checked the cables: a copy of NAS, with the tasks under it,
2039+/// and a copy of the cables task, each right after its own (§12.4).
2040+fn lab() -> (tempfile::TempDir, App) {
2041+ let text = "# Homelab\n\n## NAS\n\nMirrored pairs.\n\n- [ ] Snapshot policy\n- [x] Replace fan\n\n## Networking\n\n- [ ] Label the cables\n";
2042+ let d = vault(text);
2043+ std::fs::write(
2044+ d.path().join("root.sync-conflict-20260927-100000-PHONE.md"),
2045+ text.replace("pairs.", "pairs, no raidz.").replace("[ ] Label", "[x] Label"),
2046+ )
2047+ .unwrap();
2048+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
2049+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
2050+ assert_eq!(fold_core::merge::conflict_pairs(&v).len(), 2);
2051+ drop(v);
2052+ let app = App::new(d.path()).unwrap();
2053+ (d, app)
2054+}
2055+
2056+/// Draw a frame: the screen as cells, to read colours and find things.
2057+fn frame(app: &mut App) -> ratatui::buffer::Buffer {
2058+ let mut t = Terminal::new(TestBackend::new(120, 32)).unwrap();
2059+ t.draw(|f| app.draw(f)).unwrap();
2060+ t.backend().buffer().clone()
2061+}
2062+
2063+fn line(b: &ratatui::buffer::Buffer, y: u16) -> String {
2064+ (0..b.area.width).map(|x| b[(x, y)].symbol()).collect()
2065+}
2066+
2067+fn text(b: &ratatui::buffer::Buffer) -> String {
2068+ (0..b.area.height).map(|y| line(b, y) + "\n").collect()
2069+}
2070+
2071+/// Where `s` starts on screen, the topmost first.
2072+fn find(b: &ratatui::buffer::Buffer, s: &str) -> Option<(u16, u16)> {
2073+ (0..b.area.height).find_map(|y| {
2074+ let l = line(b, y);
2075+ l.find(s).map(|i| (l[..i].chars().count() as u16, y))
2076+ })
2077+}
2078+
2079+/// Where the ⚠ after `s` is on screen.
2080+fn warning_after(b: &ratatui::buffer::Buffer, s: &str) -> (u16, u16) {
2081+ let at = format!("{} ⚠", s);
2082+ let (x, y) = find(b, &at).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", at, text(b)));
2083+ (x + at.chars().count() as u16 - 1, y)
2084+}
2085+
2086+fn click_at(app: &mut App, x: u16, y: u16, button: MouseButton) {
2087+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(button), column: x, row: y, modifiers: KeyModifiers::NONE });
2088+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Up(button), column: x, row: y, modifiers: KeyModifiers::NONE });
2089+}
2090+
2091+/// Which pair the conflict view shows, by what is on screen.
2092+fn pair_shown(app: &mut App) -> &'static str {
2093+ assert_eq!(app.mode_pub(), "conflict");
2094+ let s = screen(app);
2095+ match (s.contains("no raidz"), s.contains("Label the cables")) {
2096+ (true, false) => "NAS",
2097+ (false, true) => "Label the cables",
2098+ _ => panic!("{}", s),
2099+ }
2100+}
2101+
2102+#[test]
2103+fn a_conflict_copy_s_row_shows_a_warning_and_whose_copy_it_is() {
2104+ let (_d, mut app) = lab();
2105+ let b = frame(&mut app);
2106+ // ⚠ in the warning colour where a block's ▤ goes, and where its text
2107+ // would go, the device and time it came from
2108+ let (x, y) = warning_after(&b, "NAS");
2109+ assert_eq!(b[(x, y)].fg, ratatui::style::Color::Yellow);
2110+ let row = line(&b, y);
2111+ assert!(row.contains("NAS ⚠ 1/2 other device · PHONE 09-27 10:00"), "{}", row);
2112+ assert!(!row.contains("raidz"), "{}", row);
2113+ warning_after(&b, "☑ Label the cables");
2114+ assert!(!text(&b).contains('▤'), "{}", text(&b));
2115+ // their tasks are not counted twice above them
2116+ assert!(text(&b).contains("Homelab 2/3"), "{}", text(&b));
2117+ assert!(text(&b).contains("Networking 1/1"), "{}", text(&b));
2118+}
2119+
2120+#[test]
2121+fn a_conflict_copy_starts_folded_and_that_fold_is_not_remembered() {
2122+ let (_d, mut app) = lab();
2123+ let s = screen(&mut app);
2124+ assert!(s.contains("▸ NAS ⚠"), "{}", s);
2125+ assert_eq!(s.matches("Snapshot policy").count(), 1, "{}", s);
2126+ // a click unfolds it as any row, for this run alone
2127+ let b = frame(&mut app);
2128+ let (x, y) = find(&b, "▸ NAS ⚠").unwrap();
2129+ click_at(&mut app, x, y, MouseButton::Left);
2130+ let s = screen(&mut app);
2131+ assert_eq!(s.matches("Snapshot policy").count(), 2, "{}", s);
2132+ assert!(app.view().folded.is_empty());
2133+ press(&mut app, "j");
2134+ let b = frame(&mut app);
2135+ let (x, y) = find(&b, "▾ NAS ⚠").unwrap();
2136+ click_at(&mut app, x, y, MouseButton::Left);
2137+ assert_eq!(screen(&mut app).matches("Snapshot policy").count(), 1);
2138+ assert!(app.view().folded.is_empty());
2139+ // zoomed into, it shows what is in it
2140+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2141+ app.handle_key(key(KeyCode::Enter));
2142+ let s = screen(&mut app);
2143+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2144+}
2145+
2146+#[test]
2147+fn a_click_on_a_copy_s_warning_opens_the_conflict_view_at_its_pair() {
2148+ // the view lists pairs by their copies' files, so each is tried
2149+ let (_d, mut app) = lab();
2150+ for copy in ["NAS", "Label the cables"] {
2151+ let b = frame(&mut app);
2152+ let (x, y) = warning_after(&b, copy);
2153+ click_at(&mut app, x, y, MouseButton::Left);
2154+ assert_eq!(pair_shown(&mut app), copy);
2155+ app.handle_key(key(KeyCode::Esc));
2156+ }
2157+}
2158+
2159+#[test]
2160+fn resolve_conflict_in_the_node_menu_of_either_side_opens_its_pair() {
2161+ let (_d, mut app) = lab();
2162+ // right-click ours, then a copy: the menu ends in Resolve conflict…
2163+ for side in ["Label the cables", "NAS ⚠"] {
2164+ let b = frame(&mut app);
2165+ let (x, y) = find(&b, side).unwrap();
2166+ click_at(&mut app, x, y, MouseButton::Right);
2167+ let b = frame(&mut app);
2168+ let (x, y) = find(&b, "Resolve conflict…").unwrap_or_else(|| panic!("{}", text(&b)));
2169+ click_at(&mut app, x, y, MouseButton::Left);
2170+ assert_eq!(pair_shown(&mut app), side.trim_end_matches(" ⚠"));
2171+ app.handle_key(key(KeyCode::Esc));
2172+ }
2173+ // a node in no pair has none
2174+ select(&mut app, "Networking");
2175+ press(&mut app, "m");
2176+ let s = screen(&mut app);
2177+ assert!(s.contains("Move to…") && !s.contains("Resolve conflict"), "{}", s);
2178+}
2179+
2180+/// Draw a frame on a `w`×`h` terminal: the screen as cells.
2181+fn frame_at(app: &mut App, w: u16, h: u16) -> ratatui::buffer::Buffer {
2182+ let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
2183+ t.draw(|f| app.draw(f)).unwrap();
2184+ t.backend().buffer().clone()
2185+}
2186+
2187+#[test]
2188+fn the_node_menu_of_a_pair_fits_a_short_terminal_resolve_conflict_and_all() {
2189+ let (_d, mut app) = lab();
2190+ // 80×24: the separators give way, every item shows and a click runs it
2191+ select(&mut app, "NAS");
2192+ press(&mut app, "m");
2193+ let b = frame_at(&mut app, 80, 24);
2194+ for item in ["│ Edit ", "│ Delete ", "│ Resolve conflict…"] {
2195+ assert!(find(&b, item).is_some(), "no {}:\n{}", item, text(&b));
2196+ }
2197+ let (x, y) = find(&b, "Resolve conflict…").unwrap();
2198+ click_at(&mut app, x, y, MouseButton::Left);
2199+ assert_eq!(pair_shown(&mut app), "NAS");
2200+ app.handle_key(key(KeyCode::Esc));
2201+ // shorter still, the list scrolls with the selection, down to the last
2202+ // item and back up to the first
2203+ press(&mut app, "m");
2204+ let b = frame_at(&mut app, 80, 14);
2205+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2206+ for _ in 0..30 {
2207+ app.handle_key(key(KeyCode::Down));
2208+ frame_at(&mut app, 80, 14);
2209+ }
2210+ let b = frame_at(&mut app, 80, 14);
2211+ assert!(find(&b, "Resolve conflict…").is_some() && find(&b, "│ Edit ").is_none(), "{}", text(&b));
2212+ for _ in 0..30 {
2213+ app.handle_key(key(KeyCode::Up));
2214+ frame_at(&mut app, 80, 14);
2215+ }
2216+ assert!(find(&frame_at(&mut app, 80, 14), "│ Edit ").is_some());
2217+ for _ in 0..30 {
2218+ app.handle_key(key(KeyCode::Down));
2219+ }
2220+ app.handle_key(key(KeyCode::Enter));
2221+ assert_eq!(pair_shown(&mut app), "NAS");
2222+}
2223+
2224+/// The pointer moves to a point and rests there.
2225+fn point(app: &mut App, x: u16, y: u16) {
2226+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Moved, column: x, row: y, modifiers: KeyModifiers::NONE });
2227+}
2228+
2229+/// Right-click NAS on an 80×14 terminal: its menu fills the screen and
2230+/// stops short of Resolve conflict…. The column its items are drawn in,
2231+/// and its first and last item rows.
2232+fn short_menu(app: &mut App) -> (u16, u16, u16) {
2233+ let b = frame_at(app, 80, 14);
2234+ let (x, y) = find(&b, "NAS").unwrap();
2235+ point(app, x, y);
2236+ click_at(app, x, y, MouseButton::Right);
2237+ let b = frame_at(app, 80, 14);
2238+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2239+ let x = find(&b, "│ Edit ").unwrap().0 + 2;
2240+ let rows: Vec<u16> = (0..14).filter(|&y| matches!(app.hit_at(x, y), Some(Hit::MenuItem(_)))).collect();
2241+ (x, rows[0], *rows.last().unwrap())
2242+}
2243+
2244+#[test]
2245+fn on_a_short_terminal_the_wheel_scrolls_the_node_menu() {
2246+ let (_d, mut app) = lab();
2247+ let (x, top, _) = short_menu(&mut app);
2248+ // over its first item, the wheel goes down to the last item, back up
2249+ // to the first, and down again for a click to run the last
2250+ for (kind, to, gone) in [
2251+ (MouseEventKind::ScrollDown, "Resolve conflict…", "│ Edit "),
2252+ (MouseEventKind::ScrollUp, "│ Edit ", "Resolve conflict…"),
2253+ (MouseEventKind::ScrollDown, "Resolve conflict…", "│ Edit "),
2254+ ] {
2255+ for _ in 0..10 {
2256+ app.handle_mouse(MouseEvent { kind, column: x, row: top, modifiers: KeyModifiers::NONE });
2257+ frame_at(&mut app, 80, 14);
2258+ }
2259+ let b = frame_at(&mut app, 80, 14);
2260+ assert!(find(&b, to).is_some() && find(&b, gone).is_none(), "{:?}:\n{}", kind, text(&b));
2261+ }
2262+ let (x, y) = find(&frame_at(&mut app, 80, 14), "Resolve conflict…").unwrap();
2263+ click_at(&mut app, x, y, MouseButton::Left);
2264+ assert_eq!(pair_shown(&mut app), "NAS");
2265+}
2266+
2267+#[test]
2268+fn a_pointer_resting_on_the_node_menu_leaves_the_keys_to_scroll_it() {
2269+ let (_d, mut app) = lab();
2270+ let (x, _, bottom) = short_menu(&mut app);
2271+ // moved onto the last row, the pointer highlights its item
2272+ point(&mut app, x, bottom);
2273+ assert_eq!(frame_at(&mut app, 80, 14)[(x, bottom)].bg, ratatui::style::Color::Indexed(24));
2274+ // resting there, ↓ goes down to the last item and ↑ back up to the
2275+ // first, which Enter runs
2276+ for _ in 0..30 {
2277+ app.handle_key(key(KeyCode::Down));
2278+ frame_at(&mut app, 80, 14);
2279+ }
2280+ let b = frame_at(&mut app, 80, 14);
2281+ assert!(find(&b, "Resolve conflict…").is_some() && find(&b, "│ Edit ").is_none(), "{}", text(&b));
2282+ for _ in 0..30 {
2283+ app.handle_key(key(KeyCode::Up));
2284+ frame_at(&mut app, 80, 14);
2285+ }
2286+ let b = frame_at(&mut app, 80, 14);
2287+ assert!(find(&b, "│ Edit ").is_some() && find(&b, "Resolve conflict…").is_none(), "{}", text(&b));
2288+ app.handle_key(key(KeyCode::Enter));
2289+ assert_eq!(app.mode_pub(), "edit");
2290+}
2291+
2292+#[test]
2293+fn the_reading_pane_marks_a_conflict_copy_s_title_line() {
2294+ let (_d, mut app) = lab();
2295+ app.show_reading = true;
2296+ select(&mut app, "Homelab");
2297+ let b = frame(&mut app);
2298+ let (x, y) = warning_after(&b, "## NAS");
2299+ assert_eq!(b[(x, y)].fg, ratatui::style::Color::Yellow);
2300+ assert!(line(&b, y).contains("## NAS ⚠ other device · PHONE 09-27 10:00"), "{}", line(&b, y));
2301+ assert_eq!(text(&b).matches("## NAS").count(), 2, "{}", text(&b));
2302+ warning_after(&b, "☑ Label the cables");
2303+ // its ⚠ opens its pair, and the pane is on Homelab after
2304+ click_at(&mut app, x, y, MouseButton::Left);
2305+ assert_eq!(pair_shown(&mut app), "NAS");
2306+ app.handle_key(key(KeyCode::Esc));
2307+ assert_eq!(selected(&app), "Homelab");
2308+ // the copy's own pane says so in its border
2309+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2310+ let b = frame(&mut app);
2311+ assert!(find(&b, "╭ NAS ⚠ ─").is_some(), "{}", text(&b));
2312+}
2313+
2314+#[test]
2315+fn enter_on_a_copy_s_heading_in_the_reading_pane_zooms_into_it_unfolded() {
2316+ let (_d, mut app) = lab();
2317+ app.show_reading = true;
2318+ select(&mut app, "Homelab");
2319+ app.handle_key(key(KeyCode::Tab));
2320+ // down to the copy's heading, the second ## NAS
2321+ let at = app.reading_doc_pub().lines.iter().rposition(|l| l.starts_with("## NAS")).unwrap();
2322+ for _ in 0..at {
2323+ press(&mut app, "j");
2324+ draw(&mut app);
2325+ }
2326+ assert_eq!(app.read_cursor_pub(), at);
2327+ app.handle_key(key(KeyCode::Enter));
2328+ // as a double-click or the outline's Enter: it shows what is in it
2329+ let s = screen(&mut app);
2330+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2331+ assert_eq!(app.rows().len(), 3, "{}", s);
2332+}
2333+
2334+/// fold started again on `app`'s view, as it remembers it (§10.1).
2335+fn restarted(d: &tempfile::TempDir, app: &App) -> App {
2336+ let mut again = App::new(d.path()).unwrap();
2337+ again.apply_view(fold_tui::app::View::parse(&app.view().to_text()).unwrap(), false);
2338+ again
2339+}
2340+
2341+#[test]
2342+fn a_zoom_on_a_conflict_copy_shows_what_is_in_it_after_a_restart() {
2343+ let (d, mut app) = lab();
2344+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2345+ app.handle_key(key(KeyCode::Enter));
2346+ assert_eq!(app.rows().len(), 3);
2347+ // the zoom is remembered, the copy's fold is not: it starts unfolded
2348+ let mut again = restarted(&d, &app);
2349+ let s = screen(&mut again);
2350+ assert!(s.contains("▾ NAS ⚠") && s.contains("Snapshot policy"), "{}", s);
2351+ assert_eq!(again.rows().len(), 3, "{}", s);
2352+ // folded there by hand, it stays folded through a change from outside
2353+ press(&mut again, "h");
2354+ assert_eq!(again.rows().len(), 1);
2355+ again.reload_external();
2356+ assert_eq!(again.rows().len(), 1, "{}", screen(&mut again));
2357+}
2358+
2359+#[test]
2360+fn zooming_out_or_a_breadcrumb_to_a_conflict_copy_shows_what_is_in_it() {
2361+ let (d, mut app) = lab();
2362+ // zoomed into a task in the copy when fold ends
2363+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2364+ app.handle_key(key(KeyCode::Enter));
2365+ select(&mut app, "Snapshot policy");
2366+ app.handle_key(key(KeyCode::Enter));
2367+ assert!(screen(&mut app).contains("fold › Homelab › NAS › Snapshot policy"), "{}", screen(&mut app));
2368+ // Backspace, in the outline or the reading pane, lands on the copy
2369+ for reading in [false, true] {
2370+ let mut again = restarted(&d, &app);
2371+ if reading {
2372+ again.handle_key(key(KeyCode::Tab));
2373+ }
2374+ again.handle_key(key(KeyCode::Backspace));
2375+ let s = screen(&mut again);
2376+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2377+ assert_eq!(again.rows().len(), 3, "{}", s);
2378+ }
2379+ // so does its breadcrumb
2380+ let mut again = restarted(&d, &app);
2381+ let b = frame(&mut again);
2382+ let (x, y) = find(&b, "NAS › Snapshot policy").unwrap_or_else(|| panic!("{}", text(&b)));
2383+ click_at(&mut again, x, y, MouseButton::Left);
2384+ let s = screen(&mut again);
2385+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2386+ assert_eq!(again.rows().len(), 3, "{}", s);
2387+ // and so does deleting the task: the zoom falls back to the copy
2388+ let mut again = restarted(&d, &app);
2389+ press(&mut again, "d");
2390+ let s = screen(&mut again);
2391+ assert!(s.contains("▾ NAS ⚠") && s.contains("Replace fan"), "{}", s);
2392+ assert_eq!(again.rows().len(), 2, "{}", s);
2393+}
2394+
2395+#[test]
2396+fn move_to_leaves_conflict_copies_out_and_other_pickers_mark_them() {
2397+ let (_d, mut app) = lab();
2398+ select(&mut app, "Replace fan");
2399+ press(&mut app, "r");
2400+ press(&mut app, "Snapshot");
2401+ draw(&mut app);
2402+ assert!(app.hit_pos(Hit::PickRow(0)).is_some());
2403+ assert!(app.hit_pos(Hit::PickRow(1)).is_none(), "{}", screen(&mut app));
2404+ app.handle_key(key(KeyCode::Esc));
2405+ // Go to… and the filter list both, the one in the copy with its ⚠
2406+ app.run_action(Action::GoTo);
2407+ press(&mut app, "Snapshot");
2408+ let b = frame(&mut app);
2409+ assert!(app.hit_pos(Hit::PickRow(1)).is_some(), "{}", text(&b));
2410+ assert!(find(&b, "Snapshot policy ⚠ Homelab › NAS").is_some(), "{}", text(&b));
2411+ app.handle_key(key(KeyCode::Esc));
2412+ press(&mut app, "/Snapshot");
2413+ let b = frame(&mut app);
2414+ assert!(app.hit_pos(Hit::FilterRow(1)).is_some(), "{}", text(&b));
2415+ assert_eq!(text(&b).matches("Snapshot policy ⚠ Homelab › NAS").count(), 1, "{}", text(&b));
2416+}
2417+
2418+#[test]
2419+fn indent_puts_nothing_into_a_conflict_copy() {
2420+ // the node above Networking is the copy of NAS, folded, which keeping
2421+ // ours trashes (§12.5)
2422+ let (d, mut app) = lab();
2423+ select(&mut app, "Networking");
2424+ press(&mut app, ">");
2425+ assert_eq!(said(&mut app), "can't indent “Networking” into a conflict copy");
2426+ assert_eq!(selected(&app), "Networking");
2427+ assert!(has_line(&d, "## Networking") && has_line(&d, "- [ ] Label the cables"), "{}", root(&d));
2428+ // inside a copy, a node still goes under the one above it
2429+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2430+ app.handle_key(key(KeyCode::Enter));
2431+ select(&mut app, "Replace fan");
2432+ press(&mut app, ">");
2433+ assert_eq!(said(&mut app), "indented “Replace fan”");
2434+}
2435+
2436+/// The node each conflict copy pairs with, the one before it (§12.4).
2437+fn paired(app: &mut App) -> Vec<String> {
2438+ let v = app.vault_mut();
2439+ let mut out: Vec<String> =
2440+ fold_core::merge::conflict_pairs(v).into_iter().map(|(ours, _)| v.tree.node(ours).title.clone()).collect();
2441+ out.sort();
2442+ out
2443+}
2444+
2445+/// The lab's pairs, each copy right after its own node.
2446+const PAIRED: [&str; 2] = ["Label the cables", "NAS"];
2447+
2448+/// Put the cursor on the copy of NAS, the second row titled so.
2449+fn select_copy(app: &mut App) {
2450+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2451+}
2452+
2453+#[test]
2454+fn j_and_k_move_a_node_and_its_conflict_copy_as_one() {
2455+ // a copy pairs with the node before it (§12.4): a node moved in
2456+ // between would pair with it, and keeping theirs would replace it
2457+ let (d, mut app) = lab();
2458+ let before = root(&d);
2459+ select(&mut app, "Networking");
2460+ press(&mut app, "K");
2461+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2462+ assert_eq!(selected(&app), "Networking");
2463+ let r = root(&d);
2464+ assert!(r.find("## Networking").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2465+ // keeping theirs replaces NAS, not what K put above its copy
2466+ let b = frame(&mut app);
2467+ let (x, y) = warning_after(&b, "NAS");
2468+ click_at(&mut app, x, y, MouseButton::Left);
2469+ assert_eq!(pair_shown(&mut app), "NAS");
2470+ press(&mut app, "t");
2471+ assert!(has_line(&d, "## Networking") && has_line(&d, "- [ ] Label the cables"), "{}", root(&d));
2472+ assert!(has_line(&d, "Mirrored pairs, no raidz."), "{}", root(&d));
2473+ press(&mut app, "u");
2474+ app.handle_key(key(KeyCode::Esc));
2475+ // J steps over the pair as K did
2476+ select(&mut app, "Networking");
2477+ press(&mut app, "J");
2478+ assert_eq!(root(&d), before);
2479+ // J and K on either side move both
2480+ select(&mut app, "NAS");
2481+ press(&mut app, "J");
2482+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2483+ assert_eq!(selected(&app), "NAS");
2484+ let r = root(&d);
2485+ assert!(r.find("## Networking").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2486+ select_copy(&mut app);
2487+ press(&mut app, "K");
2488+ assert_eq!(root(&d), before);
2489+ select(&mut app, "Label the cables");
2490+ press(&mut app, "J");
2491+ assert_eq!(root(&d), before, "nothing below it to go past");
2492+}
2493+
2494+#[test]
2495+fn a_node_added_pasted_or_dropped_beside_a_conflict_pair_goes_after_the_copy() {
2496+ let (d, mut app) = lab();
2497+ let before = root(&d);
2498+ // n on NAS: the new node, and the editor on it, after NAS's copy
2499+ select(&mut app, "NAS");
2500+ press(&mut app, "n");
2501+ assert_eq!(app.mode_pub(), "edit");
2502+ press(&mut app, "Backups");
2503+ app.handle_key(key(KeyCode::Esc));
2504+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2505+ assert_eq!(selected(&app), "Backups");
2506+ let r = root(&d);
2507+ assert!(r.find("## ![[").unwrap() < r.find("## Backups").unwrap(), "{}", r);
2508+ // the title, then the node
2509+ press(&mut app, "uu");
2510+ assert_eq!(root(&d), before);
2511+ // p on NAS and P on its copy
2512+ select(&mut app, "Networking");
2513+ press(&mut app, "y");
2514+ select(&mut app, "NAS");
2515+ press(&mut app, "p");
2516+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2517+ press(&mut app, "u");
2518+ select_copy(&mut app);
2519+ press(&mut app, "P");
2520+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2521+ press(&mut app, "u");
2522+ assert_eq!(root(&d), before);
2523+ // a drop to the left of the copy's row
2524+ draw(&mut app);
2525+ let rows = app.rows();
2526+ let copy = rows.iter().rposition(|r| app.title_of(r.nref) == "NAS").unwrap();
2527+ let from = app.hit_pos(Hit::Row(copy + 1)).unwrap();
2528+ let to = (2, app.hit_pos(Hit::Row(copy)).unwrap().1);
2529+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2530+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2531+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2532+ draw(&mut app);
2533+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2534+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2535+ assert_eq!(selected(&app), "Networking");
2536+}
2537+
2538+#[test]
2539+fn a_node_dropped_before_a_conflict_copy_is_selected_where_it_lands() {
2540+ // a second NAS, after Networking, dropped to the left of the copy of
2541+ // NAS: it lands after the copy, whose title it shares, and it, not
2542+ // the copy, is selected there
2543+ let (d, mut app) = lab();
2544+ select(&mut app, "NAS");
2545+ press(&mut app, "y");
2546+ select(&mut app, "Networking");
2547+ press(&mut app, "p");
2548+ draw(&mut app);
2549+ let rows = app.rows();
2550+ let nas: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| app.title_of(r.nref) == "NAS").map(|(i, _)| i).collect();
2551+ let [_, copy, second] = nas[..] else { panic!("{:?}", nas) };
2552+ let from = app.hit_pos(Hit::Row(second)).unwrap();
2553+ let to = (2, app.hit_pos(Hit::Row(copy)).unwrap().1);
2554+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2555+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2556+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2557+ draw(&mut app);
2558+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2559+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2560+ let cursor = app.cursor;
2561+ assert_eq!(cursor, copy + 1, "{}", screen(&mut app));
2562+ assert_eq!(selected(&app), "NAS");
2563+}
2564+
2565+/// Work's two sections, then the Inbox, merged with a phone's copy that
2566+/// added a line under Reading list: a copy of it after its list (§12.4).
2567+fn reading() -> (tempfile::TempDir, App) {
2568+ let text = "# Work\n\n## Project Atlas\n\n- [ ] Draft the RFC\n\n## Reading list\n\n- The Rust Book\n\n# Inbox\n\n- call the plumber\n";
2569+ let d = vault(text);
2570+ std::fs::write(
2571+ d.path().join("root.sync-conflict-20260927-161600-PHONE77.md"),
2572+ text.replace("## Reading list\n\n", "## Reading list\n\nBooks to read this winter.\n\n"),
2573+ )
2574+ .unwrap();
2575+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
2576+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
2577+ assert_eq!(fold_core::merge::conflict_pairs(&v).len(), 1);
2578+ drop(v);
2579+ let app = App::new(d.path()).unwrap();
2580+ (d, app)
2581+}
2582+
2583+/// The row of the node titled `title`, not of its copy.
2584+fn row_of(app: &App, title: &str) -> usize {
2585+ app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap()
2586+}
2587+
2588+/// The row of the copy of `title`, the last row titled so.
2589+fn copy_row(app: &App, title: &str) -> usize {
2590+ app.rows().iter().rposition(|r| app.title_of(r.nref) == title).unwrap()
2591+}
2592+
2593+/// Drag row `from` onto row `to`: onto its title, or left of it.
2594+fn drag(app: &mut App, from: usize, to: usize, into: bool) {
2595+ draw(app);
2596+ let from = app.hit_pos(Hit::Row(from)).unwrap();
2597+ let to = app.hit_pos(Hit::Row(to)).unwrap();
2598+ let to = if into { to } else { (2, to.1) };
2599+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2600+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2601+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2602+ draw(app);
2603+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2604+}
2605+
2606+#[test]
2607+fn move_to_takes_a_node_s_conflict_copy_with_it() {
2608+ // left behind, the copy of Reading list would pair with Project
2609+ // Atlas, and keeping theirs would replace that (§12.5)
2610+ let (d, mut app) = reading();
2611+ select(&mut app, "Reading list");
2612+ press(&mut app, "r");
2613+ press(&mut app, "Inbox");
2614+ app.handle_key(key(KeyCode::Enter));
2615+ assert_eq!(said(&mut app), "moved “Reading list” to “Inbox”");
2616+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2617+ let r = root(&d);
2618+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2619+ assert!(r.find("## Reading list").unwrap() < r.find("## ![[").unwrap(), "{}", r);
2620+ // the selection is on the node, not on its copy
2621+ assert_eq!(app.cursor, row_of(&app, "Reading list"));
2622+ // keeping theirs replaces Reading list, and Project Atlas stays
2623+ let b = frame(&mut app);
2624+ let (x, y) = warning_after(&b, "Reading list");
2625+ click_at(&mut app, x, y, MouseButton::Left);
2626+ press(&mut app, "t");
2627+ assert!(has_line(&d, "## Project Atlas") && has_line(&d, "- [ ] Draft the RFC"), "{}", root(&d));
2628+ assert!(has_line(&d, "Books to read this winter."), "{}", root(&d));
2629+ // from the copy, both go, and the selection stays on the copy; its
2630+ // node, and what is under it, is no destination, as its own subtree
2631+ let (d, mut app) = reading();
2632+ app.cursor = copy_row(&app, "Reading list");
2633+ press(&mut app, "rRust");
2634+ draw(&mut app);
2635+ assert!(app.hit_pos(Hit::PickRow(0)).is_none(), "{}", screen(&mut app));
2636+ app.handle_key(key(KeyCode::Esc));
2637+ press(&mut app, "r");
2638+ press(&mut app, "Inbox");
2639+ app.handle_key(key(KeyCode::Enter));
2640+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2641+ let r = root(&d);
2642+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2643+ assert_eq!(app.cursor, copy_row(&app, "Reading list"));
2644+}
2645+
2646+#[test]
2647+fn deleting_a_node_deletes_its_conflict_copy_with_it() {
2648+ // left behind, the copy of NAS would be first under Homelab, marked
2649+ // and folded with nothing to pair with
2650+ let (d, mut app) = lab();
2651+ let before = root(&d);
2652+ select(&mut app, "NAS");
2653+ press(&mut app, "d");
2654+ assert_eq!(said(&mut app), "deleted “NAS” and its conflict copy (6 nodes) · u undoes");
2655+ assert_eq!(paired(&mut app), ["Label the cables"], "{}", root(&d));
2656+ assert!(!app.rows().iter().any(|r| app.title_of(r.nref) == "NAS"), "{}", screen(&mut app));
2657+ assert_eq!(root(&d).matches("![[").count(), 1, "{}", root(&d));
2658+ // one u brings both back
2659+ press(&mut app, "u");
2660+ assert_eq!(root(&d), before);
2661+ assert_eq!(paired(&mut app), PAIRED);
2662+ // a copy goes alone, as keeping ours sends it
2663+ select_copy(&mut app);
2664+ press(&mut app, "d");
2665+ assert_eq!(said(&mut app), "deleted “NAS” (3 nodes) · u undoes");
2666+ assert_eq!(paired(&mut app), ["Label the cables"], "{}", root(&d));
2667+ assert!(has_line(&d, "## NAS") && has_line(&d, "Mirrored pairs."), "{}", root(&d));
2668+}
2669+
2670+#[test]
2671+fn a_drag_an_indent_or_an_outdent_takes_a_node_s_conflict_copy_with_it() {
2672+ let (d, mut app) = reading();
2673+ let before = root(&d);
2674+ // > on the copy: both go under Project Atlas, the node before them
2675+ app.cursor = copy_row(&app, "Reading list");
2676+ press(&mut app, ">");
2677+ assert_eq!(said(&mut app), "indented “Reading list”");
2678+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2679+ assert!(has_line(&d, "### Reading list") && has_line(&d, "- [ ] Draft the RFC"), "{}", root(&d));
2680+ assert_eq!(app.cursor, copy_row(&app, "Reading list"));
2681+ // < on the node: both back after Project Atlas
2682+ select(&mut app, "Reading list");
2683+ press(&mut app, "<");
2684+ assert_eq!(said(&mut app), "outdented “Reading list”");
2685+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2686+ assert_eq!(root(&d), before);
2687+ assert_eq!(app.cursor, row_of(&app, "Reading list"));
2688+ // the copy dragged into Inbox
2689+ let (from, to) = (copy_row(&app, "Reading list"), row_of(&app, "Inbox"));
2690+ drag(&mut app, from, to, true);
2691+ assert_eq!(paired(&mut app), ["Reading list"], "{}", root(&d));
2692+ let r = root(&d);
2693+ assert!(r.find("# Inbox").unwrap() < r.find("## Reading list").unwrap(), "{}", r);
2694+ // an item and its copy dragged before a section: before NAS
2695+ let (d, mut app) = lab();
2696+ let (from, to) = (row_of(&app, "Label the cables"), row_of(&app, "NAS"));
2697+ drag(&mut app, from, to, false);
2698+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2699+ let r = root(&d);
2700+ assert!(r.find("- [ ] Label the cables").unwrap() < r.find("## NAS").unwrap(), "{}", r);
2701+ assert_eq!(app.cursor, row_of(&app, "Label the cables"));
2702+ // > on a copy with nothing above its node
2703+ app.cursor = copy_row(&app, "Label the cables");
2704+ press(&mut app, ">");
2705+ assert_eq!(said(&mut app), "can't indent “Label the cables”: nothing above it");
2706+}
2707+
2708+#[test]
2709+fn a_drop_puts_nothing_into_a_conflict_copy() {
2710+ // dropped on the title of the copy of NAS, Networking would go out of
2711+ // sight under its fold, and keeping ours would trash it with the copy
2712+ // (§12.5): the drag says so while it is over the copy, and letting go
2713+ // does nothing
2714+ let (d, mut app) = lab();
2715+ let before = root(&d);
2716+ draw(&mut app);
2717+ let from = app.hit_pos(Hit::Row(row_of(&app, "Networking"))).unwrap();
2718+ let to = app.hit_pos(Hit::Row(copy_row(&app, "NAS"))).unwrap();
2719+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
2720+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2721+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2722+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2723+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2724+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2725+ assert_eq!(root(&d), before);
2726+ assert_eq!(paired(&mut app), PAIRED, "{}", root(&d));
2727+ // nor onto a row in the copy unfolded, or left of one
2728+ app.cursor = copy_row(&app, "NAS");
2729+ press(&mut app, "l");
2730+ let (from, to) = (row_of(&app, "Networking"), copy_row(&app, "Snapshot policy"));
2731+ drag(&mut app, from, to, true);
2732+ assert_eq!(said(&mut app), "can't move “Networking” into a conflict copy");
2733+ let (from, to) = (row_of(&app, "Label the cables"), copy_row(&app, "Replace fan"));
2734+ drag(&mut app, from, to, false);
2735+ assert_eq!(said(&mut app), "can't move “Label the cables” into a conflict copy");
2736+ assert_eq!(root(&d), before);
2737+ // a node in the copy still moves within it, as > moves it
2738+ let (from, to) = (copy_row(&app, "Replace fan"), copy_row(&app, "Snapshot policy"));
2739+ drag(&mut app, from, to, false);
2740+ assert_eq!(said(&mut app), "moved “Replace fan”");
2741+ assert_eq!(copy_row(&app, "Replace fan") + 1, copy_row(&app, "Snapshot policy"), "{}", screen(&mut app));
2742+ // the rows changing under a drag: the row it lets go on is now in the
2743+ // copy, and that is refused too
2744+ let (d, mut app) = lab();
2745+ let before = root(&d);
2746+ draw(&mut app);
2747+ let from = app.hit_pos(Hit::Row(row_of(&app, "Replace fan"))).unwrap();
2748+ let to = app.hit_pos(Hit::Row(row_of(&app, "Networking"))).unwrap();
2749+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
2750+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), to));
2751+ assert_eq!(said(&mut app), "move “Replace fan” into “Networking”");
2752+ app.cursor = copy_row(&app, "NAS");
2753+ press(&mut app, "l");
2754+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), to));
2755+ assert_eq!(said(&mut app), "can't move “Replace fan” into a conflict copy");
2756+ assert_eq!(root(&d), before);
2757+}
2758+
2759+#[test]
2760+fn a_node_in_a_conflict_pair_keeps_its_spelling_until_it_is_resolved() {
2761+ // a heading made a bullet goes before the headings, and would leave
2762+ // its copy to pair with the node before it (§12.5)
2763+ let (d, mut app) = lab();
2764+ let before = root(&d);
2765+ select(&mut app, "NAS");
2766+ press(&mut app, "~");
2767+ assert_eq!(said(&mut app), "can't respell “NAS”: resolve its conflict");
2768+ select_copy(&mut app);
2769+ press(&mut app, "~");
2770+ assert_eq!(said(&mut app), "can't respell “NAS”: resolve its conflict");
2771+ select(&mut app, "Label the cables");
2772+ press(&mut app, "~");
2773+ assert_eq!(said(&mut app), "can't respell “Label the cables”: resolve its conflict");
2774+ assert_eq!(root(&d), before);
2775+ assert_eq!(paired(&mut app), PAIRED);
2776+}
2777+
2778+#[test]
2779+fn the_editor_s_border_says_when_the_cursor_is_in_a_conflict_copy() {
2780+ let (_d, mut app) = lab();
2781+ app.set_edit_keys(EditKeys::Normal);
2782+ select(&mut app, "Homelab");
2783+ press(&mut app, "e");
2784+ let b = frame(&mut app);
2785+ assert!(find(&b, "Editing Homelab").is_some() && find(&b, "conflict copy").is_none(), "{}", text(&b));
2786+ // down through ours, into the copy of NAS
2787+ let mut seen = None;
2788+ for _ in 0..20 {
2789+ app.handle_key(key(KeyCode::Down));
2790+ let b = frame(&mut app);
2791+ if let Some((_, y)) = find(&b, "conflict copy") {
2792+ seen = Some(line(&b, y));
2793+ break;
2794+ }
2795+ }
2796+ let border = seen.expect("never said so");
2797+ assert!(border.contains("Editing NAS ⚠ conflict copy from PHONE"), "{}", border);
2798+}
2799+
2800+#[test]
2801+fn the_editor_s_border_says_so_when_opened_on_a_node_inside_a_conflict_copy() {
2802+ // typing there lands in the copy, which keeping ours trashes
2803+ let (_d, mut app) = lab();
2804+ app.set_edit_keys(EditKeys::Normal);
2805+ // ours: no warning
2806+ select(&mut app, "Snapshot policy");
2807+ press(&mut app, "e");
2808+ let b = frame(&mut app);
2809+ assert!(find(&b, "Editing Snapshot policy").is_some() && find(&b, "conflict copy").is_none(), "{}", text(&b));
2810+ app.handle_key(key(KeyCode::Esc));
2811+ // the copy's, a node of its file that is no block of its own
2812+ select_copy(&mut app);
2813+ press(&mut app, "l");
2814+ app.cursor = app.rows().iter().rposition(|r| app.title_of(r.nref) == "Snapshot policy").unwrap();
2815+ press(&mut app, "e");
2816+ let b = frame(&mut app);
2817+ let (_, y) = find(&b, "Editing Snapshot").unwrap_or_else(|| panic!("no editor:\n{}", text(&b)));
2818+ assert!(line(&b, y).contains("⚠ conflict copy from PHONE"), "{}", line(&b, y));
2819+ app.handle_key(key(KeyCode::Esc));
2820+ // a block of its own inside the copy
2821+ press(&mut app, "s");
2822+ let r = app.current().unwrap();
2823+ assert!(app.vault_mut().tree.node(r).is_block(), "{}", status(&mut app));
2824+ press(&mut app, "e");
2825+ let b = frame(&mut app);
2826+ let (_, y) = find(&b, "Editing Snapshot").unwrap_or_else(|| panic!("no editor:\n{}", text(&b)));
2827+ assert!(line(&b, y).contains("⚠ conflict copy from PHONE"), "{}", line(&b, y));
2828+}
2829+
2830+#[test]
2831+fn where_the_editor_s_border_is_short_its_mode_and_warning_stay_whole_left_of_its_buttons() {
2832+ // in a copy, Vim's and Helix's border says more than fits left of ⌨
2833+ // at 120 columns, and far more at 80
2834+ for (keys, name) in [(EditKeys::Vim, "vim"), (EditKeys::Helix, "helix")] {
2835+ let (_d, mut app) = lab();
2836+ app.set_edit_keys(keys);
2837+ select(&mut app, "Homelab");
2838+ press(&mut app, "e");
2839+ // down into the copy of NAS, where a wide border says so in full
2840+ for _ in 0..20 {
2841+ if find(&frame_at(&mut app, 200, 32), "conflict copy").is_some() {
2842+ break;
2843+ }
2844+ app.handle_key(key(KeyCode::Down));
2845+ }
2846+ assert!(find(&frame_at(&mut app, 200, 32), "conflict copy").is_some(), "{}", text(&frame_at(&mut app, 200, 32)));
2847+ // clean, in insert mode, then with something typed
2848+ for (typed, mode) in [("", "NORMAL"), ("i", "INSERT"), ("z", "INSERT")] {
2849+ press(&mut app, typed);
2850+ for (w, h) in [(120, 32), (80, 24)] {
2851+ let b = frame_at(&mut app, w, h);
2852+ let (_, y) = find(&b, "⌨").unwrap_or_else(|| panic!("no ⌨:\n{}", text(&b)));
2853+ let row = line(&b, y);
2854+ let border = &row[row.rfind('╭').unwrap()..];
2855+ let (left, right) = border.split_at(border.find('⌨').unwrap());
2856+ let why = format!("{} {}×{} after “{}”: {}", name, w, h, typed, border);
2857+ // the mode and the ⚠ whole, left of ⌨
2858+ assert!(left.contains(&format!(" {} ", mode)) && left.contains('⚠'), "{}", why);
2859+ if w == 120 {
2860+ assert!(left.contains("Editing NAS ⚠"), "{}", why);
2861+ }
2862+ // right of it only the keymap and the buttons
2863+ let rest = right.replacen(&format!("⌨ {}", name), "", 1).replace("✓ Done", "").replace("↺ Revert", "");
2864+ assert!(rest.chars().all(|c| matches!(c, '─' | ' ' | '╮' | '✓' | '↺')), "{}", why);
2865+ }
2866+ }
2867+ }
2868+}
2869+
2870+// ------------------------------------------------------------ messages
2871+
2872+/// The sample vault, as `fold` ships it, cut down.
2873+const SAMPLE: &str = "# Homelab\n\nTwo boxes in the closet.\n\n## NAS\n\nMirrored pairs, no raidz.\n\n### [ ] Snapshot policy\n\n- hourly, keep 24\n- daily, keep 30\n\n### [x] Replace fan\n\n## Networking\n\n- [ ] Replace the flaky switch\n- [ ] Label the cables\n - [x] patch panel\n - [ ] rack\n- VLANs: 10 home, 20 iot, 30 guest\n\n# Work\n\n## Project Atlas\n\n- [ ] Draft the RFC\n- [ ] Review PR\n- [x] Kickoff meeting\n\n## Reading list\n\n- The Rust Book\n\n# Inbox\n\n- call the plumber\n- [ ] renew passport\n";
2874+
2875+/// What the status bar says on its left, past a mode's badge, as the
2876+/// next frame draws it.
2877+fn said(app: &mut App) -> String {
2878+ let s = status(app);
2879+ let mut parts = s.split(" ").map(str::trim).filter(|p| !p.is_empty());
2880+ let first = parts.next().unwrap_or_default();
2881+ let badge = first.chars().all(|c| c.is_ascii_uppercase());
2882+ if badge { parts.next().unwrap_or_default() } else { first }.to_string()
2883+}
2884+
2885+#[test]
2886+fn copy_and_paste_name_the_node_in_the_menu_s_words() {
2887+ let d = vault(SAMPLE);
2888+ let mut app = App::new(d.path()).unwrap();
2889+ select(&mut app, "NAS");
2890+ press(&mut app, "p");
2891+ assert_eq!(said(&mut app), "nothing copied yet · y copies a node");
2892+ press(&mut app, "y");
2893+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
2894+ select(&mut app, "Reading list");
2895+ press(&mut app, "p");
2896+ assert_eq!(said(&mut app), "pasted “NAS”");
2897+ // the Copy button of the node menu says the same
2898+ select(&mut app, "Work");
2899+ app.run_action(Action::Copy);
2900+ assert_eq!(said(&mut app), "copied “Work” · p pastes");
2901+}
2902+
2903+#[test]
2904+fn checking_a_task_names_it_and_a_node_that_is_no_task_says_so() {
2905+ let d = vault(SAMPLE);
2906+ let mut app = App::new(d.path()).unwrap();
2907+ select(&mut app, "NAS");
2908+ press(&mut app, "x");
2909+ assert_eq!(said(&mut app), "“NAS” isn't a task · t makes it one");
2910+ assert_eq!(root(&d), SAMPLE);
2911+ press(&mut app, "u");
2912+ assert_eq!(said(&mut app), "nothing to undo");
2913+ select(&mut app, "Draft the RFC");
2914+ press(&mut app, "x");
2915+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
2916+ press(&mut app, "x");
2917+ assert_eq!(said(&mut app), "reopened: “Draft the RFC”");
2918+ press(&mut app, "t");
2919+ assert_eq!(said(&mut app), "removed the checkbox from “Draft the RFC”");
2920+ press(&mut app, "t");
2921+ assert_eq!(said(&mut app), "made “Draft the RFC” a task");
2922+ // x in the reading pane, on the line of a task, says the same
2923+ app.show_reading = true;
2924+ select(&mut app, "Project Atlas");
2925+ app.handle_key(key(KeyCode::Tab));
2926+ press(&mut app, "jj");
2927+ press(&mut app, "x");
2928+ assert!(root(&d).contains("- [x] Draft the RFC"), "{}", root(&d));
2929+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
2930+}
2931+
2932+#[test]
2933+fn delete_and_make_block_name_the_node_and_show_no_file_or_id() {
2934+ let d = vault(SAMPLE);
2935+ let mut app = App::new(d.path()).unwrap();
2936+ select(&mut app, "Homelab");
2937+ press(&mut app, "d");
2938+ assert_eq!(said(&mut app), "deleted “Homelab” (12 nodes) · u undoes");
2939+ press(&mut app, "u");
2940+ select(&mut app, "rack");
2941+ press(&mut app, "s");
2942+ assert_eq!(said(&mut app), "gave “rack” its own file");
2943+ // a block is deleted like any node: by name, its file unsaid
2944+ press(&mut app, "d");
2945+ assert_eq!(said(&mut app), "deleted “rack” · u undoes");
2946+}
2947+
2948+#[test]
2949+fn moves_name_the_node_and_say_in_words_where_the_ordering_rule_put_it() {
2950+ let d = vault(SAMPLE);
2951+ let mut app = App::new(d.path()).unwrap();
2952+ select(&mut app, "rack");
2953+ press(&mut app, ">");
2954+ assert_eq!(said(&mut app), "indented “rack”");
2955+ press(&mut app, "<");
2956+ assert_eq!(said(&mut app), "outdented “rack”");
2957+ // nowhere to go: nothing changed, and the words do not say it did
2958+ let before = root(&d);
2959+ select(&mut app, "patch panel");
2960+ press(&mut app, ">");
2961+ assert_eq!(said(&mut app), "can't indent “patch panel”: nothing above it");
2962+ select(&mut app, "Homelab");
2963+ press(&mut app, "<");
2964+ assert_eq!(said(&mut app), "can't outdent “Homelab”: it's at the top level");
2965+ assert_eq!(root(&d), before);
2966+ // out of a heading among headings, an item goes before them
2967+ select(&mut app, "hourly, keep 24");
2968+ press(&mut app, "<");
2969+ assert_eq!(said(&mut app), "outdented “hourly, keep 24” — placed before the sections");
2970+ // a bullet made a heading goes after the bullets
2971+ select(&mut app, "Replace the flaky switch");
2972+ press(&mut app, "~");
2973+ assert_eq!(said(&mut app), "made “Replace the flaky switch” a heading — placed after the items");
2974+ // a heading made a bullet, before the headings
2975+ select(&mut app, "Replace fan");
2976+ press(&mut app, "~");
2977+ assert_eq!(said(&mut app), "made “Replace fan” a bullet — placed before the sections");
2978+ // Move to… names where to
2979+ select(&mut app, "call the plumber");
2980+ press(&mut app, "r");
2981+ press(&mut app, "Project Atlas");
2982+ app.handle_key(key(KeyCode::Enter));
2983+ assert_eq!(said(&mut app), "moved “call the plumber” to “Project Atlas”");
2984+ select(&mut app, "renew passport");
2985+ press(&mut app, "r");
2986+ press(&mut app, "Homelab");
2987+ app.handle_key(key(KeyCode::Enter));
2988+ assert_eq!(said(&mut app), "moved “renew passport” to “Homelab” — placed before the sections");
2989+ select(&mut app, "Kickoff meeting");
2990+ press(&mut app, "za");
2991+ assert_eq!(said(&mut app), "archived “Kickoff meeting”");
2992+ assert!(!status(&mut app).contains('§'));
2993+}
2994+
2995+#[test]
2996+fn a_drop_the_ordering_rule_placed_says_where_in_words() {
2997+ let d = vault("# A\n\n- a1\n\n## S\n\n# B\n\n- b1\n");
2998+ let mut app = App::new(d.path()).unwrap();
2999+ // rows: A, a1, S, B, b1; b1 onto A's title goes into A, before S
3000+ draw(&mut app);
3001+ let (from, onto) = (app.hit_pos(Hit::Row(4)).unwrap(), app.hit_pos(Hit::Row(0)).unwrap());
3002+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
3003+ app.handle_mouse(at(MouseEventKind::Down(MouseButton::Left), from));
3004+ app.handle_mouse(at(MouseEventKind::Drag(MouseButton::Left), onto));
3005+ draw(&mut app);
3006+ app.handle_mouse(at(MouseEventKind::Up(MouseButton::Left), onto));
3007+ let r = root(&d);
3008+ assert!(r.find("- b1").is_some_and(|i| i < r.find("## S").unwrap()), "{}", r);
3009+ assert_eq!(said(&mut app), "moved “b1” — placed before the sections");
3010+}
3011+
3012+#[test]
3013+fn clear_done_counts_the_done_tasks_it_cleared() {
3014+ let d = vault(SAMPLE);
3015+ let mut app = App::new(d.path()).unwrap();
3016+ // under the zoom, only there
3017+ select(&mut app, "Networking");
3018+ app.handle_key(key(KeyCode::Enter));
3019+ app.run_action(Action::ClearDone);
3020+ assert_eq!(said(&mut app), "cleared 1 done task under “Networking”");
3021+ app.handle_key(key(KeyCode::Backspace));
3022+ app.handle_key(key(KeyCode::Backspace));
3023+ app.run_action(Action::ClearDone);
3024+ assert_eq!(said(&mut app), "cleared 2 done tasks");
3025+ app.run_action(Action::ClearDone);
3026+ assert_eq!(said(&mut app), "no done tasks to clear");
3027+}
3028+
3029+#[test]
3030+fn the_editor_s_own_saves_say_nothing_and_ctrl_s_says_saved() {
3031+ let d = vault("# Inbox\n\nnotes\n");
3032+ let mut app = App::new(d.path()).unwrap();
3033+ app.set_edit_keys(EditKeys::Normal);
3034+ press(&mut app, "e");
3035+ app.handle_key(key(KeyCode::End));
3036+ app.handle_key(key(KeyCode::Char('!')));
3037+ // the pause saves it; the status bar's right side says so, the left
3038+ // side nothing
3039+ std::thread::sleep(Duration::from_millis(800));
3040+ app.tick();
3041+ assert_eq!(root(&d), "# Inbox!\n\nnotes\n");
3042+ let s = said(&mut app);
3043+ assert!(!s.contains("saved") && !s.contains("block"), "{}", s);
3044+ // leaving saves too, as quietly
3045+ app.handle_key(key(KeyCode::Char('?')));
3046+ app.handle_key(key(KeyCode::Esc));
3047+ assert_eq!(root(&d), "# Inbox!?\n\nnotes\n");
3048+ let s = said(&mut app);
3049+ assert!(!s.contains("saved") && !s.contains("block"), "{}", s);
3050+ // Ctrl-S is asked for, and answered
3051+ press(&mut app, "e");
3052+ app.handle_key(key(KeyCode::End));
3053+ app.handle_key(key(KeyCode::Char('.')));
3054+ app.handle_key(ctrl('s'));
3055+ assert_eq!(root(&d), "# Inbox!?.\n\nnotes\n");
3056+ assert_eq!(said(&mut app), "saved");
3057+}
3058+
3059+#[test]
3060+fn where_the_bar_is_short_a_message_naming_two_nodes_cuts_the_longer_name_first() {
3061+ // 80×24: the file gives way, then the long destination, not the node
3062+ // that moved nor the words after it
3063+ let title = "Project Atlas quarterly planning and review notes";
3064+ let d = vault(&format!("# Networking\n\n- [ ] Label the cables\n - [ ] rack\n\n# {}\n\n- [ ] Draft the RFC\n\n## Milestones\n", title));
3065+ let mut app = App::new(d.path()).unwrap();
3066+ select(&mut app, "rack");
3067+ press(&mut app, "r");
3068+ press(&mut app, "Project Atlas");
3069+ app.handle_key(key(KeyCode::Enter));
3070+ let s = status80(&mut app);
3071+ assert!(s.starts_with(" moved “rack” to “Project Atlas quarter…” — placed before the sections ✓ saved "), "{}", s);
3072+ // wider, it is all there
3073+ assert_eq!(said(&mut app), format!("moved “rack” to “{}” — placed before the sections", title));
3074+ // the undo names both too
3075+ press(&mut app, "u");
3076+ let s = status80(&mut app);
3077+ assert!(s.starts_with(" undone: move “rack” to “Project Atlas quarterly planning and review…” ✓ saved "), "{}", s);
3078+}
3079+
3080+#[test]
3081+fn indent_and_outdent_refusals_fit_beside_the_badges_at_80_columns() {
3082+ // under a conflict copy, which comes with the ⚠ count: the count
3083+ // comes down to its number, and the reason and the name stay whole
3084+ let text = "# Networking\n\n- [ ] Replace the flaky switch\n- [ ] Label the cables\n";
3085+ let d = vault(text);
3086+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), text.replace("[ ] Replace", "[x] Replace")).unwrap();
3087+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
3088+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
3089+ drop(v);
3090+ let mut app = App::new(d.path()).unwrap();
3091+ press(&mut app, "G>");
3092+ let s = status80(&mut app);
3093+ assert!(s.starts_with(" can't indent “Label the cables” into a conflict copy "), "{}", s);
3094+ assert!(s.ends_with(" ⚠ 1 root.md ✓ saved "), "{}", s);
3095+ assert_eq!(said(&mut app), "can't indent “Label the cables” into a conflict copy");
3096+ // with done hidden, nothing above and the top level, name and all
3097+ let d = vault(SAMPLE);
3098+ let mut app = App::new(d.path()).unwrap();
3099+ press(&mut app, "zd");
3100+ select(&mut app, "Homelab");
3101+ press(&mut app, ">");
3102+ let s = status80(&mut app);
3103+ assert!(s.starts_with(" can't indent “Homelab”: nothing above it "), "{}", s);
3104+ assert!(s.contains(" done hidden "), "{}", s);
3105+ press(&mut app, "<");
3106+ let s = status80(&mut app);
3107+ assert!(s.starts_with(" can't outdent “Homelab”: it's at the top level "), "{}", s);
3108+ assert_eq!(root(&d), SAMPLE);
3109+}
3110+
3111+#[test]
3112+fn the_spelling_refusal_on_a_conflict_pair_fits_beside_the_badges_at_80_columns() {
3113+ // it comes only while a pair exists, so with the ⚠ count, and done
3114+ // hidden is a remembered view: the reason stays whole, and the name
3115+ let d = vault(SAMPLE);
3116+ let theirs = SAMPLE.replace("no raidz.", "no raidz, one spare.").replace("[ ] Replace the", "[x] Replace the");
3117+ std::fs::write(d.path().join("root.sync-conflict-20260927-100000-PHONE.md"), theirs).unwrap();
3118+ let mut v = fold_core::vault::Vault::open(d.path()).unwrap();
3119+ fold_core::merge::merge_sync_conflicts(&mut v, false).unwrap();
3120+ drop(v);
3121+ let before = root(&d);
3122+ let mut app = App::new(d.path()).unwrap();
3123+ press(&mut app, "zd");
3124+ select(&mut app, "NAS");
3125+ press(&mut app, "~");
3126+ let s = status80(&mut app);
3127+ assert!(s.starts_with(" can't respell “NAS”: resolve its conflict "), "{}", s);
3128+ assert!(s.contains(" done hidden "), "{}", s);
3129+ select_copy(&mut app);
3130+ press(&mut app, "~");
3131+ let s = status80(&mut app);
3132+ assert!(s.starts_with(" can't respell “NAS”: resolve its conflict "), "{}", s);
3133+ // a long name gives way, not the reason
3134+ select(&mut app, "Replace the flaky switch");
3135+ press(&mut app, "~");
3136+ let s = status80(&mut app);
3137+ assert!(s.starts_with(" can't respell “Replace the …”: resolve its conflict "), "{}", s);
3138+ assert_eq!(said(&mut app), "can't respell “Replace the flaky switch”: resolve its conflict");
3139+ assert_eq!(root(&d), before);
3140+}
3141+
3142+// ------------------------------------------------------------ the next step
3143+
3144+/// What the status bar says once its message has gone: the keys of what
3145+/// is on screen (§10.1).
3146+const OUTLINE_KEYS: &str = "n new · e edit · x done · m menu · / find · ? help";
3147+const READING_KEYS: &str = "e edit · Enter zoom/follow · Tab outline";
3148+const EDITOR_KEYS: &str = "Esc done · Ctrl-S save · Ctrl-Z undo";
3149+const VIM_KEYS: &str = "i insert · :q! revert · :wq done";
3150+const VIM_INSERT_KEYS: &str = "Esc normal mode · :q! revert · :wq done";
3151+const PROPS_KEYS: &str = "n add · Enter change · d delete · Esc close";
3152+
3153+#[test]
3154+fn a_message_gives_way_to_the_keys_once_it_is_old_and_something_was_done_since() {
3155+ let d = vault(SAMPLE);
3156+ let mut app = App::new(d.path()).unwrap();
3157+ select(&mut app, "NAS");
3158+ press(&mut app, "y");
3159+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
3160+ // a key since, but the message is new: it stays
3161+ press(&mut app, "j");
3162+ assert_eq!(said(&mut app), "copied “NAS” · p pastes");
3163+ // five seconds on, with no key since the key: its time is over
3164+ std::thread::sleep(Duration::from_millis(5100));
3165+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3166+}
3167+
3168+#[test]
3169+fn a_message_no_key_came_after_stays_until_one_does() {
3170+ // for someone who looked away: the message waits for them
3171+ let d = vault(SAMPLE);
3172+ let mut app = App::new(d.path()).unwrap();
3173+ select(&mut app, "Draft the RFC");
3174+ press(&mut app, "x");
3175+ std::thread::sleep(Duration::from_millis(5100));
3176+ assert_eq!(said(&mut app), "done: “Draft the RFC”");
3177+ press(&mut app, "k");
3178+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3179+ // the greeting is a message like any other
3180+ let mut app = App::new(d.path()).unwrap();
3181+ assert!(said(&mut app).contains("right-click for actions"), "{}", said(&mut app));
3182+}
3183+
3184+#[test]
3185+fn a_message_a_click_said_stays_through_the_click_s_own_release() {
3186+ // the button let go, or a wobble while it is down, is the click that
3187+ // said it, not a click since; a drag's release ends the drag, and
3188+ // what the drag said on its way goes as before
3189+ let at = |kind, (column, row)| MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE };
3190+ let (down, drag, up) = (
3191+ MouseEventKind::Down(MouseButton::Left),
3192+ MouseEventKind::Drag(MouseButton::Left),
3193+ MouseEventKind::Up(MouseButton::Left),
3194+ );
3195+ let on = |app: &mut App, title: &str, hit: fn(usize) -> Hit| {
3196+ draw(app);
3197+ let i = app.rows().iter().position(|r| app.title_of(r.nref) == title).unwrap();
3198+ app.hit_pos(hit(i)).unwrap()
3199+ };
3200+ // rack's ☐, clicked
3201+ let d1 = vault(SAMPLE);
3202+ let mut clicked = App::new(d1.path()).unwrap();
3203+ let p = on(&mut clicked, "rack", Hit::Check);
3204+ clicked.handle_mouse(at(down, p));
3205+ clicked.handle_mouse(at(up, p));
3206+ assert_eq!(said(&mut clicked), "done: “rack”");
3207+ // Review PR's, with the pointer a cell off by the time it is let go
3208+ let d2 = vault(SAMPLE);
3209+ let mut wobbled = App::new(d2.path()).unwrap();
3210+ let p = on(&mut wobbled, "Review PR", Hit::Check);
3211+ wobbled.handle_mouse(at(down, p));
3212+ wobbled.handle_mouse(at(drag, (p.0 + 1, p.1)));
3213+ wobbled.handle_mouse(at(up, (p.0 + 1, p.1)));
3214+ assert_eq!(said(&mut wobbled), "done: “Review PR”");
3215+ // NAS dragged along its own row and let go there
3216+ let d3 = vault(SAMPLE);
3217+ let mut dragged = App::new(d3.path()).unwrap();
3218+ let p = on(&mut dragged, "NAS", Hit::Row);
3219+ let over = (p.0 + 5, p.1);
3220+ dragged.handle_mouse(at(down, p));
3221+ dragged.handle_mouse(at(drag, over));
3222+ dragged.handle_mouse(at(up, over));
3223+ assert!(said(&mut dragged).starts_with("moving “NAS”"), "{}", said(&mut dragged));
3224+ std::thread::sleep(Duration::from_millis(5100));
3225+ assert_eq!(said(&mut clicked), "done: “rack”");
3226+ assert_eq!(said(&mut wobbled), "done: “Review PR”");
3227+ assert_eq!(said(&mut dragged), OUTLINE_KEYS);
3228+ // the next click is one since
3229+ let p = on(&mut clicked, "NAS", Hit::Row);
3230+ clicked.handle_mouse(at(down, p));
3231+ clicked.handle_mouse(at(up, p));
3232+ assert_eq!(said(&mut clicked), OUTLINE_KEYS);
3233+}
3234+
3235+#[test]
3236+fn an_error_or_refusal_stays_until_the_next_key_after_it_was_read() {
3237+ let d = vault(SAMPLE);
3238+ let mut app = App::new(d.path()).unwrap();
3239+ select(&mut app, "Homelab");
3240+ press(&mut app, "<");
3241+ let refusal = "can't outdent “Homelab”: it's at the top level";
3242+ assert_eq!(said(&mut app), refusal);
3243+ // a key right after, while it was being read: it stays, however long
3244+ press(&mut app, "j");
3245+ std::thread::sleep(Duration::from_millis(5100));
3246+ assert_eq!(said(&mut app), refusal);
3247+ // the next key gets it out of the way
3248+ press(&mut app, "k");
3249+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3250+}
3251+
3252+#[test]
3253+fn each_mode_shows_its_own_keys() {
3254+ let d = vault(SAMPLE);
3255+ let mut app = App::new(d.path()).unwrap();
3256+ // the outline's greeting does not follow into the editor, where ?
3257+ // types a ?
3258+ select(&mut app, "NAS");
3259+ press(&mut app, "e");
3260+ let s = said(&mut app);
3261+ assert!(s == EDITOR_KEYS || s == VIM_KEYS, "{}", s);
3262+ app.handle_key(key(KeyCode::Esc));
3263+ // once a message has gone, the keys of what is on screen
3264+ app.set_edit_keys(EditKeys::Normal);
3265+ app.say("");
3266+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3267+ press(&mut app, "e");
3268+ assert_eq!(said(&mut app), EDITOR_KEYS);
3269+ app.handle_key(key(KeyCode::Esc));
3270+ // in Vim and Helix, Esc never leaves the editor
3271+ for keys in [EditKeys::Vim, EditKeys::Helix] {
3272+ app.set_edit_keys(keys);
3273+ press(&mut app, "e");
3274+ app.say("");
3275+ assert_eq!(said(&mut app), VIM_KEYS);
3276+ press(&mut app, "i");
3277+ assert_eq!(said(&mut app), VIM_INSERT_KEYS);
3278+ app.handle_key(key(KeyCode::Esc));
3279+ assert_eq!(said(&mut app), VIM_KEYS);
3280+ press(&mut app, ":wq");
3281+ app.handle_key(key(KeyCode::Enter));
3282+ assert_eq!(app.mode_pub(), "normal");
3283+ assert_eq!(said(&mut app), OUTLINE_KEYS);
3284+ }
3285+ press(&mut app, "a");
3286+ assert_eq!(said(&mut app), PROPS_KEYS);
3287+ app.handle_key(key(KeyCode::Esc));
3288+ app.show_reading = true;
3289+ app.handle_key(key(KeyCode::Tab));
3290+ assert_eq!(said(&mut app), READING_KEYS);
3291+ assert_eq!(root(&d), SAMPLE);
3292+}
3293+
3294+#[test]
3295+fn the_conflict_view_shows_its_keys_while_it_has_a_pair_to_resolve() {
3296+ let (_d, mut app) = lab();
3297+ app.run_action(Action::ResolveConflicts);
3298+ app.say("");
3299+ assert_eq!(said(&mut app), "o ours · t theirs · b both · n next · Esc close");
3300+ press(&mut app, "tt");
3301+ assert_eq!(pairs(&mut app), 0);
3302+ app.say("");
3303+ assert_eq!(said(&mut app), "Esc close");
3304+}
3305+
3306+#[test]
3307+fn z_and_g_show_what_can_follow_and_a_wrong_second_key_says_so() {
3308+ let d = vault(SAMPLE);
3309+ let mut app = App::new(d.path()).unwrap();
3310+ press(&mut app, "z");
3311+ assert_eq!(said(&mut app), "z… p pane · w wrap · d hide done · r raw · a archive");
3312+ // zq quits nothing, and says so
3313+ press(&mut app, "q");
3314+ assert!(!app.quit_requested());
3315+ assert_eq!(said(&mut app), "zq does nothing · after z press p, w, d, r or a");
3316+ press(&mut app, "g");
3317+ assert_eq!(said(&mut app), "g… g top");
3318+ app.handle_key(key(KeyCode::Enter));
3319+ assert_eq!(said(&mut app), "g Enter does nothing · after g press g");
3320+ // Esc lets the first key go, quietly
3321+ press(&mut app, "z");
3322+ app.handle_key(key(KeyCode::Esc));
3323+ assert_eq!(said(&mut app), "g Enter does nothing · after g press g");
3324+ // zd's words say what it would do now, and lead while done is hidden
3325+ press(&mut app, "zd");
3326+ press(&mut app, "z");
3327+ assert_eq!(said(&mut app), "z… d show done · p pane · w wrap · r raw · a archive");
3328+ app.handle_key(key(KeyCode::Esc));
3329+ // [[ and ]] in the reading pane
3330+ app.show_reading = true;
3331+ app.handle_key(key(KeyCode::Tab));
3332+ press(&mut app, "]");
3333+ assert_eq!(said(&mut app), "]… ] next heading");
3334+ assert_eq!(root(&d), SAMPLE);
3335+}
3336+
3337+#[test]
3338+fn a_click_lets_a_half_typed_key_go() {
3339+ // z, then a click on ⌕ Filter: what is typed next is the filter's,
3340+ // and its a archives nothing
3341+ let d = vault(SAMPLE);
3342+ let mut app = App::new(d.path()).unwrap();
3343+ select(&mut app, "NAS");
3344+ press(&mut app, "z");
3345+ click_button(&mut app, Action::Filter);
3346+ assert_eq!(app.mode_pub(), "filter");
3347+ assert_eq!(said(&mut app), "↑↓ pick · Enter go · Esc close");
3348+ press(&mut app, "archive");
3349+ let s = screen(&mut app);
3350+ assert!(s.contains("archive"), "{}", s);
3351+ assert!(!s.contains("does nothing"), "{}", s);
3352+ assert_eq!(root(&d), SAMPLE, "a key after z and a click archived");
3353+ app.handle_key(key(KeyCode::Esc));
3354+ // + Capture: the prompt takes every letter
3355+ press(&mut app, "g");
3356+ click_button(&mut app, Action::Capture);
3357+ press(&mut app, "gas bill");
3358+ app.handle_key(key(KeyCode::Enter));
3359+ assert!(root(&d).contains("\n- gas bill\n"), "{}", root(&d));
3360+ // ✎ Edit, over the reading pane: the editor takes the letter
3361+ app.set_edit_keys(EditKeys::Normal);
3362+ app.show_reading = true;
3363+ select(&mut app, "call the plumber");
3364+ press(&mut app, "z");
3365+ click_button(&mut app, Action::Edit);
3366+ assert_eq!(app.mode_pub(), "edit");
3367+ press(&mut app, "a");
3368+ app.handle_key(key(KeyCode::Esc));
3369+ assert!(root(&d).contains("\na- call the plumber\n"), "{}", root(&d));
3370+ // a click on a row: the next key is a key of its own
3371+ press(&mut app, "z");
3372+ let (x, y) = find(&frame(&mut app), "Networking").unwrap();
3373+ click_at(&mut app, x, y, MouseButton::Left);
3374+ assert!(!said(&mut app).starts_with("z…"), "{}", said(&mut app));
3375+ press(&mut app, "a");
3376+ assert_eq!(app.mode_pub(), "props");
3377+ app.handle_key(key(KeyCode::Esc));
3378+ assert!(root(&d).contains("## Networking\n"), "{}", root(&d));
3379+}
3380+
3381+#[test]
3382+fn keys_fold_does_not_use_say_what_to_press_instead() {
3383+ let d = vault(SAMPLE);
3384+ let mut app = App::new(d.path()).unwrap();
3385+ select(&mut app, "NAS");
3386+ press(&mut app, "i");
3387+ assert_eq!(said(&mut app), "i does nothing here: e edits");
3388+ press(&mut app, "o");
3389+ assert_eq!(said(&mut app), "o does nothing here: n adds a node below");
3390+ app.handle_key(key(KeyCode::Delete));
3391+ assert_eq!(said(&mut app), "Delete does nothing here: d deletes");
3392+ app.handle_key(ctrl('z'));
3393+ assert_eq!(said(&mut app), "Ctrl-Z does nothing here: u undoes");
3394+ app.handle_key(ctrl('f'));
3395+ assert_eq!(said(&mut app), "Ctrl-F does nothing here: / finds");
3396+ assert_eq!(root(&d), SAMPLE);
3397+ assert_eq!(app.mode_pub(), "normal");
3398+ // F1 is help, from the outline, the reading pane and the editor, where
3399+ // ? types a ?
3400+ app.handle_key(key(KeyCode::F(1)));
3401+ assert_eq!(app.mode_pub(), "help");
3402+ app.handle_key(key(KeyCode::Esc));
3403+ app.show_reading = true;
3404+ app.handle_key(key(KeyCode::Tab));
3405+ press(&mut app, "i");
3406+ assert_eq!(said(&mut app), "i does nothing here: e edits");
3407+ app.handle_key(ctrl('f'));
3408+ assert_eq!(said(&mut app), "Ctrl-F does nothing here: / searches");
3409+ app.handle_key(key(KeyCode::Delete));
3410+ assert_eq!(said(&mut app), "Delete does nothing here: m, then choose Delete");
3411+ app.handle_key(key(KeyCode::F(1)));
3412+ assert_eq!(app.mode_pub(), "help");
3413+ app.handle_key(key(KeyCode::Esc));
3414+ app.set_edit_keys(EditKeys::Normal);
3415+ press(&mut app, "e");
3416+ app.handle_key(key(KeyCode::F(1)));
3417+ assert_eq!(app.mode_pub(), "help");
3418+ app.handle_key(key(KeyCode::Esc));
3419+ assert_eq!(app.mode_pub(), "edit");
3420+ app.handle_key(key(KeyCode::Esc));
3421+ assert_eq!(root(&d), SAMPLE);
3422+}
3423+
3424+#[test]
3425+fn delete_in_the_reading_pane_points_at_the_menu_of_the_line_s_node() {
3426+ let d = vault(SAMPLE);
3427+ let mut app = App::new(d.path()).unwrap();
3428+ app.show_reading = true;
3429+ select(&mut app, "NAS");
3430+ app.handle_key(key(KeyCode::Tab));
3431+ // down to a child's heading: the pane shows NAS, the line is Replace fan
3432+ let at = app.reading_doc_pub().lines.iter().position(|l| l.contains("Replace fan")).unwrap();
3433+ for _ in 0..at {
3434+ press(&mut app, "j");
3435+ draw(&mut app);
3436+ }
3437+ app.handle_key(key(KeyCode::Delete));
3438+ // Tab, then d would delete NAS, the outline's selection
3439+ assert_eq!(said(&mut app), "Delete does nothing here: m, then choose Delete");
3440+ assert_eq!(root(&d), SAMPLE);
3441+ // done as it says, it deletes the line's node and nothing else
3442+ press(&mut app, "m");
3443+ // ↓ down to Delete, the menu's last item
3444+ for _ in 0..node_menu_index(Action::Delete) {
3445+ app.handle_key(key(KeyCode::Down));
3446+ }
3447+ app.handle_key(key(KeyCode::Enter));
3448+ assert_eq!(said(&mut app), "deleted “Replace fan” · u undoes");
3449+ assert_eq!(root(&d), SAMPLE.replace("### [x] Replace fan\n\n", ""));
3450+}
3451+
3452+/// `m` in the reading pane, then a click on `a` in the menu it opens.
3453+fn choose(app: &mut App, a: Action) {
3454+ press(app, "m");
3455+ draw(app);
3456+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(a))).unwrap();
3457+ app.handle_mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column: x, row: y, modifiers: KeyModifiers::NONE });
3458+}
3459+
3460+#[test]
3461+fn a_verb_from_the_reading_pane_s_menu_names_the_next_step_in_that_menu() {
3462+ let d = vault(SAMPLE);
3463+ let mut app = App::new(d.path()).unwrap();
3464+ app.show_reading = true;
3465+ select(&mut app, "NAS");
3466+ app.handle_key(key(KeyCode::Tab));
3467+ // y, p, P and t are the outline's: here they do nothing
3468+ press(&mut app, "ypPt");
3469+ assert_eq!(root(&d), SAMPLE);
3470+ choose(&mut app, Action::PasteAfter);
3471+ assert_eq!(said(&mut app), "nothing copied yet · m, then choose Copy");
3472+ press(&mut app, "x");
3473+ assert_eq!(said(&mut app), "“NAS” isn't a task · m, then choose Task on / off");
3474+ choose(&mut app, Action::Copy);
3475+ assert_eq!(said(&mut app), "copied “NAS” · m, then choose Paste after");
3476+ press(&mut app, "pP");
3477+ assert_eq!(root(&d), SAMPLE);
3478+ // the menu's Done / reopen says what the pane's x does
3479+ choose(&mut app, Action::ToggleDone);
3480+ assert_eq!(said(&mut app), "“NAS” isn't a task · m, then choose Task on / off");
3481+ assert_eq!(root(&d), SAMPLE);
3482+ // done as each says, it acts on the line's node
3483+ choose(&mut app, Action::ToggleTask);
3484+ assert_eq!(said(&mut app), "made “NAS” a task");
3485+ let at = app.reading_doc_pub().lines.iter().position(|l| l.contains("Replace fan")).unwrap();
3486+ for _ in 0..at {
3487+ press(&mut app, "j");
3488+ draw(&mut app);
3489+ }
3490+ choose(&mut app, Action::PasteAfter);
3491+ assert_eq!(said(&mut app), "pasted “NAS”");
3492+ assert_eq!(root(&d).matches("NAS\n").count(), 2, "{}", root(&d));
3493+ // in the outline, the keys are the outline's
3494+ app.handle_key(key(KeyCode::Tab));
3495+ press(&mut app, "y");
3496+ assert!(said(&mut app).ends_with(" · p pastes"), "{}", said(&mut app));
3497+}
3498+
3499+/// Right-click the outline row that starts `title`, then click `a` in the
3500+/// menu it opens.
3501+fn right_click(app: &mut App, title: &str, a: Action) {
3502+ let b = frame(app);
3503+ let (x, y) = find(&b, title).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", title, text(&b)));
3504+ click_at(app, x, y, MouseButton::Right);
3505+ draw(app);
3506+ let (x, y) = app.hit_pos(Hit::MenuItem(node_menu_index(a))).unwrap();
3507+ click_at(app, x, y, MouseButton::Left);
3508+}
3509+
3510+#[test]
3511+fn a_verb_run_while_editing_names_the_pointer_s_next_step_not_a_key_the_editor_types() {
3512+ let d = vault(SAMPLE);
3513+ let mut app = App::new(d.path()).unwrap();
3514+ select(&mut app, "NAS");
3515+ press(&mut app, "e");
3516+ // every key is text here (§10.6): the step is a row's menu, or ↶ Undo
3517+ right_click(&mut app, "Networking", Action::PasteAfter);
3518+ assert_eq!(said(&mut app), "nothing copied yet · right-click, Copy");
3519+ right_click(&mut app, "Networking", Action::ToggleDone);
3520+ assert_eq!(said(&mut app), "“Networking” isn't a task · right-click, Task on / off");
3521+ right_click(&mut app, "Networking", Action::Copy);
3522+ assert_eq!(said(&mut app), "copied “Networking” · right-click, Paste after");
3523+ right_click(&mut app, "VLANs: 10 home", Action::Delete);
3524+ assert_eq!(said(&mut app), "deleted “VLANs: 10 home, 20 iot, 30 guest” · click ↶ Undo");
3525+ assert_eq!(app.mode_pub(), "edit");
3526+ // done as it says, it undoes, and the editor stays
3527+ click_button(&mut app, Action::Undo);
3528+ assert_eq!(root(&d), SAMPLE);
3529+ assert_eq!(app.mode_pub(), "edit");
3530+ // the palette's verbs, run while editing, say the same
3531+ click_button(&mut app, Action::Palette);
3532+ press(&mut app, "Copy");
3533+ app.handle_key(key(KeyCode::Enter));
3534+ assert!(said(&mut app).ends_with(" · right-click, Paste after"), "{}", said(&mut app));
3535+ // the editor done, the keys are the outline's again
3536+ app.handle_key(key(KeyCode::Esc));
3537+ select(&mut app, "Networking");
3538+ press(&mut app, "y");
3539+ assert_eq!(said(&mut app), "copied “Networking” · p pastes");
3540+ select(&mut app, "rack");
3541+ press(&mut app, "d");
3542+ assert_eq!(said(&mut app), "deleted “rack” · u undoes");
3543+}
3544+
3545+#[test]
3546+fn at_80_columns_the_editor_s_pointer_steps_keep_the_node_and_the_item_to_choose() {
3547+ // 80×24 while editing: EDIT and ✓ saved leave 62 columns once the file
3548+ // gives way, 53 beside it, and with done hidden 49 (§10.1)
3549+ for hide in [false, true] {
3550+ let d = vault(SAMPLE);
3551+ let mut app = App::new(d.path()).unwrap();
3552+ select(&mut app, "NAS");
3553+ if hide {
3554+ press(&mut app, "zd");
3555+ }
3556+ press(&mut app, "e");
3557+ right_click(&mut app, "Networking", Action::PasteAfter);
3558+ let s = status80(&mut app);
3559+ assert!(s.starts_with(" EDIT nothing copied yet · right-click, Copy "), "{}", s);
3560+ assert!(s.ends_with(" root.md ✓ saved "), "{}", s);
3561+ right_click(&mut app, "Networking", Action::Copy);
3562+ let s = status80(&mut app);
3563+ assert!(s.starts_with(" EDIT copied “Networking” · right-click, Paste after "), "{}", s);
3564+ assert!(s.ends_with(if hide { " done hidden ✓ saved " } else { " root.md ✓ saved " }), "{}", s);
3565+ // the longest: the file gives way, and with done hidden the name
3566+ // is cut, never the item to choose
3567+ right_click(&mut app, "Networking", Action::ToggleDone);
3568+ let s = status80(&mut app);
3569+ if hide {
3570+ assert!(s.starts_with(" EDIT “Netw…” isn't a task · right-click, Task on / off done hidden "), "{}", s);
3571+ } else {
3572+ assert!(s.starts_with(" EDIT “Networking” isn't a task · right-click, Task on / off "), "{}", s);
3573+ assert!(s.ends_with(" ✓ saved "), "{}", s);
3574+ }
3575+ assert_eq!(root(&d), SAMPLE);
3576+ assert_eq!(app.mode_pub(), "edit");
3577+ }
3578+}
3579+
3580+#[test]
3581+fn undo_and_redo_say_in_words_what_they_undid() {
3582+ let d = vault(SAMPLE);
3583+ let mut app = App::new(d.path()).unwrap();
3584+ select(&mut app, "Draft the RFC");
3585+ press(&mut app, "xu");
3586+ assert_eq!(said(&mut app), "undone: mark “Draft the RFC” done");
3587+ press(&mut app, "U");
3588+ assert_eq!(said(&mut app), "redone: mark “Draft the RFC” done");
3589+ press(&mut app, "xu");
3590+ assert_eq!(said(&mut app), "undone: reopen “Draft the RFC”");
3591+ press(&mut app, "U");
3592+ assert_eq!(said(&mut app), "redone: reopen “Draft the RFC”");
3593+ select(&mut app, "Homelab");
3594+ press(&mut app, "du");
3595+ assert_eq!(said(&mut app), "undone: delete “Homelab”");
3596+ select(&mut app, "rack");
3597+ press(&mut app, ">u");
3598+ assert_eq!(said(&mut app), "undone: indent “rack”");
3599+ select(&mut app, "rack");
3600+ press(&mut app, "tu");
3601+ assert_eq!(said(&mut app), "undone: remove the checkbox from “rack”");
3602+ select(&mut app, "call the plumber");
3603+ press(&mut app, "r");
3604+ press(&mut app, "Project Atlas");
3605+ app.handle_key(key(KeyCode::Enter));
3606+ press(&mut app, "u");
3607+ assert_eq!(said(&mut app), "undone: move “call the plumber” to “Project Atlas”");
3608+ // the editor's saves, by the node written
3609+ app.set_edit_keys(EditKeys::Normal);
3610+ select(&mut app, "NAS");
3611+ press(&mut app, "e");
3612+ app.handle_key(key(KeyCode::End));
3613+ press(&mut app, " box");
3614+ app.handle_key(key(KeyCode::Esc));
3615+ press(&mut app, "u");
3616+ assert_eq!(said(&mut app), "undone: edit “NAS”");
3617+ // the reading pane's x, as the outline's
3618+ app.show_reading = true;
3619+ select(&mut app, "Project Atlas");
3620+ app.handle_key(key(KeyCode::Tab));
3621+ press(&mut app, "jjxu");
3622+ assert_eq!(said(&mut app), "undone: mark “Draft the RFC” done");
3623+ assert_eq!(root(&d), SAMPLE);
3624+ // a refusal names the file, and the step in words (§10.10)
3625+ app.handle_key(key(KeyCode::Tab));
3626+ select(&mut app, "rack");
3627+ press(&mut app, "x");
3628+ std::fs::write(d.path().join("root.md"), root(&d).replace("VLANs", "VLAN")).unwrap();
3629+ press(&mut app, "u");
3630+ assert_eq!(said(&mut app), "undo refused: root.md changed since mark “rack” done; not overwriting");
3631+}
3632+
3633+#[test]
3634+fn a_hint_too_long_for_the_bar_loses_whole_parts_and_keeps_the_way_to_help() {
3635+ let d = vault(SAMPLE);
3636+ let mut app = App::new(d.path()).unwrap();
3637+ press(&mut app, "zd");
3638+ app.say("");
3639+ // 80 columns, with done hidden, the file and the save state on the right
3640+ let mut t = Terminal::new(TestBackend::new(80, 24)).unwrap();
3641+ t.draw(|f| app.draw(f)).unwrap();
3642+ let b = t.backend().buffer().clone();
3643+ let bar: String = (0..80).map(|x| b[(x, 23)].symbol()).collect();
3644+ assert!(bar.contains("n new · e edit · x done · m menu · ? help "), "{}", bar);
3645+ assert!(bar.contains("done hidden") && !bar.contains('…'), "{}", bar);
3646+}
3647+
3648+#[test]
3649+fn a_short_bar_keeps_the_key_that_shows_hidden_done_tasks_in_the_z_hint() {
3650+ // 80 columns, with the conflicts and done hidden on the right
3651+ let (_d, mut app) = lab();
3652+ press(&mut app, "zd");
3653+ app.say("");
3654+ press(&mut app, "z");
3655+ let bar = status80(&mut app);
3656+ assert!(bar.contains("⚠ 2 conflicts") && bar.contains("done hidden"), "{}", bar);
3657+ assert!(bar.contains("d show done") && bar.contains("p pane"), "{}", bar);
3658+ assert_eq!(bar.matches('…').count(), 1, "{}", bar);
3659+ // Esc lets z go; zd shows them again
3660+ app.handle_key(key(KeyCode::Esc));
3661+ press(&mut app, "zd");
3662+ assert!(!status80(&mut app).contains("done hidden"));
3663+}
3664+
3665+#[test]
3666+fn a_short_bar_keeps_the_vim_and_helix_editor_s_way_out_that_saves() {
3667+ // 80 columns, editing a block: its file on the right leaves the keys
3668+ // too little room, and :q! revert goes before :wq done
3669+ for title in ["Snapshot policy", "Snapshot policy for tank"] {
3670+ let d = vault(&SAMPLE.replace("Snapshot policy", title));
3671+ let mut app = App::new(d.path()).unwrap();
3672+ select(&mut app, title);
3673+ press(&mut app, "s");
3674+ for keys in [EditKeys::Vim, EditKeys::Helix] {
3675+ app.set_edit_keys(keys);
3676+ press(&mut app, "e");
3677+ app.say("");
3678+ let bar = status80(&mut app);
3679+ assert!(bar.contains("i insert") && bar.contains(":wq done"), "{}", bar);
3680+ press(&mut app, "i");
3681+ let bar = status80(&mut app);
3682+ assert!(bar.contains("Esc normal mode") && bar.contains(":wq done"), "{}", bar);
3683+ assert!(!bar.contains(":q! revert"), "{}", bar);
3684+ app.handle_key(key(KeyCode::Esc));
3685+ press(&mut app, ":wq");
3686+ app.handle_key(key(KeyCode::Enter));
3687+ assert_eq!(app.mode_pub(), "normal");
3688+ }
3689+ }
3690+}
3691+
3692+// ------------------------------------------------------------ popups
3693+
3694+/// Right-click inside the popup whose top border starts `title`, off its
3695+/// rows and buttons: the first cell within its border.
3696+fn right_click_in(app: &mut App, title: &str) {
3697+ let b = frame(app);
3698+ let at = format!("╭ {}", title);
3699+ let (x, y) = find(&b, &at).unwrap_or_else(|| panic!("no “{}” on screen:\n{}", at, text(&b)));
3700+ click_at(app, x + 1, y + 1, MouseButton::Right);
3701+}
3702+
3703+/// Whether the last frame drew the node menu.
3704+fn menu_shown(app: &App) -> bool {
3705+ app.hit_pos(Hit::MenuItem(node_menu_index(Action::Delete))).is_some()
3706+}
3707+
3708+#[test]
3709+fn a_right_click_in_the_property_form_opens_no_menu_on_the_node_behind_it() {
3710+ // the form covers the reading pane: a right-click in it is on no row
3711+ // or line (§10.1). The node menu it opened there ran Delete on the
3712+ // form's own node, and the next frame drew the form on a node gone
3713+ let d = vault(SAMPLE);
3714+ let mut app = App::new(d.path()).unwrap();
3715+ press(&mut app, "G");
3716+ assert_eq!(selected(&app), "renew passport");
3717+ press(&mut app, "a");
3718+ right_click_in(&mut app, "Properties — renew passport");
3719+ let b = frame(&mut app);
3720+ assert!(!menu_shown(&app), "{}", text(&b));
3721+ assert_eq!(app.mode_pub(), "props");
3722+ assert!(text(&b).contains("Properties — renew passport"), "{}", text(&b));
3723+ assert_eq!(root(&d), SAMPLE);
3724+ // Esc closes the form, and the selection is where it was
3725+ app.handle_key(key(KeyCode::Esc));
3726+ assert_eq!(app.mode_pub(), "normal");
3727+ assert_eq!(selected(&app), "renew passport");
3728+}
3729+
3730+#[test]
3731+fn a_right_click_in_any_popup_opens_no_menu_on_the_node_behind_it() {
3732+ // the filter's hits and a prompt's picks are nodes too: a verb from a
3733+ // menu behind them renumbers what they list
3734+ let d = vault(SAMPLE);
3735+ let mut app = App::new(d.path()).unwrap();
3736+ app.show_reading = true;
3737+ select(&mut app, "NAS");
3738+ for (keys, title) in [("/", "Filter"), (":", "Commands"), ("?", "Help"), ("r", "Move to — pick a node")] {
3739+ press(&mut app, keys);
3740+ right_click_in(&mut app, title);
3741+ let b = frame(&mut app);
3742+ assert!(!menu_shown(&app), "{}: {}", title, text(&b));
3743+ assert!(find(&b, &format!("╭ {}", title)).is_some(), "{} closed:\n{}", title, text(&b));
3744+ app.handle_key(key(KeyCode::Esc));
3745+ assert_eq!(app.mode_pub(), "normal", "{}", title);
3746+ }
3747+ assert_eq!(root(&d), SAMPLE);
3748+}
3749+
3750+/// The help's lines as its popup starts them: 60 characters, room for
3751+/// which there is at 80 columns.
3752+fn help_lines() -> Vec<String> {
3753+ fold_tui::app::help_text()
3754+ .iter()
3755+ .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect::<String>())
3756+ .map(|l| l.chars().take(60).collect::<String>().trim_end().to_string())
3757+ .filter(|l| !l.is_empty())
3758+ .collect()
3759+}
3760+
3761+#[test]
3762+fn the_help_scrolls_where_the_terminal_is_too_short_for_it() {
3763+ // every popup is a list you can scroll (§10.1). From the Vim editor at
3764+ // 120×32 the help ended at the normal keymap's ^K line, the vim/helix
3765+ // and mouse lines out of reach, and at 80×24 so was the whole EDITOR
3766+ // section: j, ↓ and the wheel did nothing
3767+ let lines = help_lines();
3768+ let (first, last) = (&lines[0], &lines[lines.len() - 1]);
3769+ let d = vault(SAMPLE);
3770+ let mut app = App::new(d.path()).unwrap();
3771+ // its keys say so
3772+ press(&mut app, "?");
3773+ assert_eq!(said(&mut app), "↑↓ scroll · Esc close");
3774+ app.handle_key(key(KeyCode::Esc));
3775+ app.set_edit_keys(EditKeys::Vim);
3776+ press(&mut app, "je");
3777+ for (w, h) in [(120, 32), (80, 24)] {
3778+ for (down, up) in [(key(KeyCode::Char('j')), key(KeyCode::Char('k'))), (key(KeyCode::Down), key(KeyCode::Up))] {
3779+ app.handle_key(key(KeyCode::F(1)));
3780+ let mut seen = text(&frame_at(&mut app, w, h));
3781+ assert!(seen.contains(first.as_str()) && !seen.contains(last.as_str()), "{}×{}:\n{}", w, h, seen);
3782+ // down to its last line, every line on the way
3783+ for _ in 0..lines.len() {
3784+ app.handle_key(down);
3785+ seen += &text(&frame_at(&mut app, w, h));
3786+ }
3787+ let b = text(&frame_at(&mut app, w, h));
3788+ assert!(b.contains(last.as_str()) && !b.contains(first.as_str()), "{}×{} {:?}:\n{}", w, h, down.code, b);
3789+ for l in &lines {
3790+ assert!(seen.contains(l.as_str()), "{}×{} {:?}: never shown: {}", w, h, down.code, l);
3791+ }
3792+ // and back up to its first
3793+ for _ in 0..lines.len() {
3794+ app.handle_key(up);
3795+ }
3796+ let b = text(&frame_at(&mut app, w, h));
3797+ assert!(b.contains(first.as_str()) && !b.contains(last.as_str()), "{}×{} {:?}:\n{}", w, h, up.code, b);
3798+ app.handle_key(key(KeyCode::Esc));
3799+ assert_eq!(app.mode_pub(), "edit");
3800+ }
3801+ }
3802+ // the wheel over it scrolls it too, where it reached nothing
3803+ app.handle_key(key(KeyCode::F(1)));
3804+ wheel(&mut app, Hit::Popup, 10);
3805+ let s = screen(&mut app);
3806+ assert!(s.contains("vim/helix the usual modes") && s.contains(last.as_str()), "{}", s);
3807+ wheel(&mut app, Hit::Popup, -10);
3808+ let s = screen(&mut app);
3809+ assert!(s.contains(first.as_str()) && !s.contains(last.as_str()), "{}", s);
3810+ // opened again, it starts at the top
3811+ wheel(&mut app, Hit::Popup, 10);
3812+ app.handle_key(key(KeyCode::Esc));
3813+ app.handle_key(key(KeyCode::F(1)));
3814+ assert!(screen(&mut app).contains(first.as_str()));
3815+ app.handle_key(key(KeyCode::Esc));
3816+ // none of it reached the editor behind
3817+ assert_eq!(app.mode_pub(), "edit");
3818+ app.run_action(Action::EditDone);
3819+ assert_eq!(root(&d), SAMPLE);
3820+}