@@ -1,3820 +0,0 @@
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−}