authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 13:49:32-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 14:23:18-07:00
log0fa3ee68b6f6521e11d14b69f59fbb546c6ad09e
tree62981ddac83304516cc784ed1d29702d3b47fad5
parent577710e1d47638bbd086a22366de263d858f4475
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: replay tests wait for the app to settle instead of fixed waits

`settle` and `accessibility` now also wait for loads, pages opening, work started with spawn (template art, version copies, renames) and the search index jobs sent but not answered, seen quiet on two frames apart so whatever a finishing thread sent has been handled. Search counts pending jobs in place of its busy flag, which was false until the index thread picked a job up. Every replay test now settles in place of its fixed waits, except after a resize, which the window system does. Assisted-by: claude-opus-5.5

4 files changed, 85 insertions(+), 55 deletions(-)

crates/snowbound/src/main.rs+33-11
......@@ -111,7 +111,11 @@ use std::{
111111 collections::{HashMap, HashSet},
112112 error::Error,
113113 path::{Path, PathBuf},
114 sync::{Arc, Mutex, mpsc},
114 sync::{
115 Arc, Mutex,
116 atomic::{AtomicUsize, Ordering},
117 mpsc,
118 },
115119};
116120use ui::{Axis, Flags, Id, Spec, Theme, Ui, children, fill, fit, px};
117121use web_time::Instant;
......@@ -886,7 +890,8 @@ struct State {
886890 snapshot: Option<PathBuf>,
887891 /// A replay waiting for nothing to be on its way, and where it wants the accessibility
888892 /// tree written then.
889 replay_settle: Option<(Option<PathBuf>, std::sync::mpsc::Sender<()>)>,
893 /// Whether the last frame found the window quiet too, which the tree waits for.
894 replay_settle: Option<(Option<PathBuf>, std::sync::mpsc::Sender<()>, bool)>,
890895 /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by
891896 /// milliseconds since the window opened.
892897 frames: Option<(PathBuf, Instant)>,
......@@ -5629,7 +5634,15 @@ fn page_text(editor: &CanvasEditor) -> String {
56295634
56305635/// Runs `work` beside the frame: on a thread of its own, or in the browser, which gives the
56315636/// page one thread, once the frame under way is done.
5637/// Work [`spawn`] started that has not ended.
5638static RUNNING: AtomicUsize = AtomicUsize::new(0);
5639
56325640fn spawn(work: impl FnOnce() + Send + 'static) {
5641 RUNNING.fetch_add(1, Ordering::Relaxed);
5642 let work = move || {
5643 work();
5644 RUNNING.fetch_sub(1, Ordering::Relaxed);
5645 };
56335646 #[cfg(not(target_arch = "wasm32"))]
56345647 std::thread::spawn(work);
56355648 #[cfg(target_arch = "wasm32")]
......@@ -5714,7 +5727,9 @@ impl State {
57145727 }
57155728 }
57165729 Replay::Snapshot(path) => self.snapshot = Some(path),
5717 Replay::Settle(path, settled) => self.replay_settle = Some((path, settled)),
5730 Replay::Settle(path, settled) => {
5731 self.replay_settle = Some((path, settled, false));
5732 }
57185733 Replay::Tick | Replay::Quit => {}
57195734 Replay::Appearance(appearance) => {
57205735 self.window.set_theme(Some(appearance));
......@@ -5730,15 +5745,22 @@ impl State {
57305745 if let Err(error) = self.frame() {
57315746 eprintln!("{error}");
57325747 }
5733 if self.settled()
5734 && let Some((path, settled)) = self.replay_settle.take()
5735 {
5736 if let Some(path) = path
5737 && let Err(error) = self.write_accessibility(&path)
5738 {
5739 eprintln!("{error}");
5748 // Quiet on two frames apart, what a thread sent as it ended has been taken.
5749 let quiet = self.settled()
5750 && self.opening.is_none()
5751 && RUNNING.load(Ordering::Relaxed) == 0
5752 && self.search.pending.load(Ordering::Relaxed) == 0;
5753 if let Some((path, settled, was)) = self.replay_settle.take() {
5754 if !(quiet && was) {
5755 self.replay_settle = Some((path, settled, quiet));
5756 } else {
5757 if let Some(path) = path
5758 && let Err(error) = self.write_accessibility(&path)
5759 {
5760 eprintln!("{error}");
5761 }
5762 let _ = settled.send(());
57405763 }
5741 let _ = settled.send(());
57425764 }
57435765 }
57445766 #[cfg(not(target_arch = "wasm32"))]
crates/snowbound/src/pane.rs+2-2
......@@ -754,7 +754,7 @@ impl State {
754754 if found.is_empty() {
755755 let status = if self.search.query.trim().is_empty() {
756756 ""
757 } else if self.search.busy.load(Ordering::Relaxed) {
757 } else if self.search.pending.load(Ordering::Relaxed) > 0 {
758758 "Searching…"
759759 } else {
760760 "No matches"
......@@ -837,7 +837,7 @@ impl State {
837837 mut unchecked: bool,
838838 mut scope: TagScope,
839839 ) {
840 let status = if self.search.busy.load(Ordering::Relaxed) {
840 let status = if self.search.pending.load(Ordering::Relaxed) > 0 {
841841 "Searching…"
842842 } else {
843843 "Search completed"
crates/snowbound/src/search.rs+17-11
......@@ -14,7 +14,7 @@ use std::{
1414 path::{Path, PathBuf},
1515 sync::{
1616 Arc, Mutex, Weak,
17 atomic::{AtomicBool, AtomicU64, Ordering},
17 atomic::{AtomicU64, AtomicUsize, Ordering},
1818 mpsc,
1919 },
2020 time::{Duration, SystemTime},
......@@ -115,7 +115,8 @@ pub struct Search {
115115 pub(crate) index: Arc<Mutex<Index>>,
116116 /// Counts the index's changes, so results are made again when it changes.
117117 version: Arc<AtomicU64>,
118 pub(crate) busy: Arc<AtomicBool>,
118 /// Jobs sent the index and not yet answered.
119 pub(crate) pending: Arc<AtomicUsize>,
119120 jobs: mpsc::Sender<Job>,
120121 /// The index's own state, which the browser, having no thread for it, keeps here to run
121122 /// the jobs sent after each frame.
......@@ -140,10 +141,14 @@ impl Search {
140141 let (jobs, receiver) = mpsc::channel();
141142 let index = Arc::new(Mutex::new(Index::default()));
142143 let version = Arc::new(AtomicU64::new(0));
143 let busy = Arc::new(AtomicBool::new(false));
144 let pending = Arc::new(AtomicUsize::new(0));
144145 let indexing = Indexing {
145146 jobs: receiver,
146 shared: (Arc::clone(&index), Arc::clone(&version), Arc::clone(&busy)),
147 shared: (
148 Arc::clone(&index),
149 Arc::clone(&version),
150 Arc::clone(&pending),
151 ),
147152 redraw,
148153 stamps: HashMap::new(),
149154 };
......@@ -163,7 +168,7 @@ impl Search {
163168 reveal: None,
164169 index,
165170 version,
166 busy,
171 pending,
167172 jobs,
168173 #[cfg(target_arch = "wasm32")]
169174 indexing,
......@@ -189,6 +194,7 @@ impl Search {
189194 }
190195
191196 fn send(&self, job: Job) {
197 self.pending.fetch_add(1, Ordering::Relaxed);
192198 let _ = self.jobs.send(job);
193199 #[cfg(target_arch = "wasm32")]
194200 {
......@@ -239,7 +245,7 @@ pub(crate) fn now() -> u64 {
239245/// The index thread's jobs and what it keeps between them.
240246struct Indexing {
241247 jobs: mpsc::Receiver<Job>,
242 shared: (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicBool>),
248 shared: (Arc<Mutex<Index>>, Arc<AtomicU64>, Arc<AtomicUsize>),
243249 redraw: std::task::Waker,
244250 stamps: HashMap<String, Stamp>,
245251}
......@@ -249,7 +255,6 @@ impl Indexing {
249255 #[cfg(not(target_arch = "wasm32"))]
250256 fn run(mut self) {
251257 while let Ok(first) = self.jobs.recv() {
252 self.shared.2.store(true, Ordering::Relaxed);
253258 // Typing sends a job a keystroke; a pause gathers them into one read.
254259 if matches!(first, Job::Pages { .. }) {
255260 std::thread::sleep(SETTLE);
......@@ -261,13 +266,14 @@ impl Indexing {
261266 /// Answers `first` and the jobs waiting behind it, the newest notebooks first, then page
262267 /// changes, and wakes the window once the index changed.
263268 fn answer(&mut self, first: Job) {
264 let (index, version, busy) = &self.shared;
269 let (index, version, pending) = &self.shared;
265270 let (jobs, stamps) = (&self.jobs, &mut self.stamps);
266 busy.store(true, Ordering::Relaxed);
267271 let mut notebooks = None;
268272 let mut changed = HashSet::new();
269273 let mut pages: HashMap<String, (Weak<Replica>, HashSet<ExGuid>)> = HashMap::new();
274 let mut answered = 0;
270275 for job in std::iter::once(first).chain(jobs.try_iter()) {
276 answered += 1;
271277 match job {
272278 Job::Notebooks {
273279 libraries,
......@@ -300,7 +306,7 @@ impl Indexing {
300306 version.fetch_add(1, Ordering::Relaxed);
301307 }
302308 lap("index", start);
303 busy.store(false, Ordering::Relaxed);
309 pending.fetch_sub(answered, Ordering::Relaxed);
304310 self.redraw.wake_by_ref();
305311 }
306312}
......@@ -933,7 +939,7 @@ impl State {
933939 // OneNote's status line: whether the search is done, and where it looked.
934940 let status = if query.is_empty() {
935941 "Search In:"
936 } else if self.search.busy.load(Ordering::Relaxed) {
942 } else if self.search.pending.load(Ordering::Relaxed) > 0 {
937943 "Searching:"
938944 } else if found.is_empty() {
939945 "No matches:"
crates/snowbound/tests/replay.rs+33-31
......@@ -38,13 +38,13 @@ fn replay(scratch: &Scratch, notebook: Option<&Path>, steps: &[&str]) -> Vec<Str
3838 std::fs::copy(entry.path(), dir.join("notebook").join(entry.file_name())).unwrap();
3939 }
4040 }
41 let mut script = String::from("wait 500\n");
41 let mut script = String::from("settle\n");
4242 let mut trees = Vec::new();
4343 for step in steps {
4444 match step.strip_prefix("accessibility ") {
4545 Some(name) => {
4646 let path = dir.join(format!("{name}.txt"));
47 script += &format!("accessibility {}\nwait 100\n", path.display());
47 script += &format!("accessibility {}\n", path.display());
4848 trees.push(path);
4949 }
5050 None => script += &format!("{step}\n"),
......@@ -115,6 +115,7 @@ fn each_toolbar_control_has_a_name_of_its_own_at_every_width() {
115115 let mut steps = Vec::new();
116116 for width in ["1600", "1180", "900", "600"] {
117117 steps.push(format!("resize {width} 760"));
118 // The window system resizes the window, which settling does not wait for.
118119 steps.push("wait 300".into());
119120 steps.push(format!("accessibility {width}"));
120121 }
......@@ -140,17 +141,17 @@ fn the_palette_lists_recent_pages_and_the_actions_a_context_menu_offers() {
140141 let scratch = Scratch::new("palette-actions");
141142 let notebook =
142143 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
143 let palette = ["modifiers command", "key p", "modifiers", "wait 400"];
144 let chord = ["modifiers command", "key k", "modifiers", "wait 400"];
144 let palette = ["modifiers command", "key p", "modifiers", "settle"];
145 let chord = ["modifiers command", "key k", "modifiers", "settle"];
145146 let mut steps = Vec::from(palette);
146 steps.extend(["type type over", "wait 200", "key Enter", "wait 800"]);
147 steps.extend(["type type over", "settle", "key Enter", "settle"]);
147148 steps.extend(palette);
148149 steps.push("accessibility recent");
149150 steps.extend(chord);
150151 steps.push("accessibility actions");
151 steps.extend(["key Escape", "wait 400", "accessibility back"]);
152 steps.extend(["key Escape", "wait 400", "move 1000 87", "press right"]);
153 steps.extend(["release right", "wait 400", "accessibility context"]);
152 steps.extend(["key Escape", "accessibility back"]);
153 steps.extend(["key Escape", "settle", "move 1000 87", "press right"]);
154 steps.extend(["release right", "accessibility context"]);
154155 let [recent, actions, back, context] = replay(&scratch, Some(&notebook), &steps)
155156 .try_into()
156157 .unwrap();
......@@ -201,11 +202,11 @@ fn undo_walks_back_through_new_pages_and_their_titles() {
201202 let notebook =
202203 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
203204 let chord =
204 |modifiers: &str, key: &str| [modifiers, key, "modifiers", "wait 800"].map(String::from);
205 |modifiers: &str, key: &str| [modifiers, key, "modifiers", "settle"].map(String::from);
205206 let mut steps = Vec::new();
206207 for title in ["type One", "type Two"] {
207208 steps.extend(chord("modifiers command", "key n"));
208 steps.extend(["settle", title, "wait 300"].map(String::from));
209 steps.push(title.into());
209210 }
210211 steps.extend(chord("modifiers command control", "key Left"));
211212 steps.push("accessibility back".into());
......@@ -267,11 +268,7 @@ fn search(query: &str, name: &str) -> Vec<String> {
267268 ["modifiers command", "key e", "modifiers"]
268269 .into_iter()
269270 .map(String::from)
270 .chain([
271 format!("type {query}"),
272 "wait 2500".into(),
273 format!("accessibility {name}"),
274 ])
271 .chain([format!("type {query}"), format!("accessibility {name}")])
275272 .collect()
276273}
277274
......@@ -279,13 +276,13 @@ fn search(query: &str, name: &str) -> Vec<String> {
279276fn version_menu() -> Vec<String> {
280277 let mut steps: Vec<String> = ["modifiers command shift", "key p", "modifiers"]
281278 .into_iter()
282 .chain(["type Page Versions", "wait 300", "key Enter", "wait 1000"])
279 .chain(["type Page Versions", "settle", "key Enter", "settle"])
283280 .map(String::from)
284281 .collect();
285282 // The version row under the page, then the yellow bar above the version.
286283 for point in ["1003 114", "177 94"] {
287284 steps.extend([format!("move {point}"), "press".into(), "release".into()]);
288 steps.push("wait 1200".into());
285 steps.push("settle".into());
289286 }
290287 steps
291288}
......@@ -301,10 +298,10 @@ fn search_finds_what_a_template_put_on_the_page() {
301298 let scratch = Scratch::new("template-search");
302299 let notebook =
303300 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
304 let mut steps: Vec<String> = ["modifiers command", "key n", "modifiers", "wait 1200"]
301 let mut steps: Vec<String> = ["modifiers command", "key n", "modifiers", "settle"]
305302 .into_iter()
306303 // The Meeting tile of the template strip over the new page.
307 .chain(["move 615 287", "press", "release", "wait 3000"])
304 .chain(["move 615 287", "press", "release", "settle"])
308305 .map(String::from)
309306 .collect();
310307 steps.extend(search("Attendees", "found"));
......@@ -325,7 +322,7 @@ fn search_finds_a_restored_version() {
325322 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore");
326323 let mut steps = version_menu();
327324 // Restore Version.
328 steps.extend(["key Down", "key Enter", "wait 2000"].map(String::from));
325 steps.extend(["key Down", "key Enter", "settle"].map(String::from));
329326 steps.extend(search("Second author", "found"));
330327 let [found] = run(&scratch, &notebook, &steps).try_into().unwrap();
331328 assert!(!search_results(&found).is_empty(), "{found}");
......@@ -339,8 +336,8 @@ fn a_version_copied_into_its_section_is_listed_and_found() {
339336 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/page-versions/candidate-restore");
340337 let mut steps = version_menu();
341338 // Copy Page To…, then the section itself.
342 steps.extend(["key Down", "key Down", "key Down", "key Enter", "wait 600"].map(String::from));
343 steps.extend(["key Down", "key Enter", "wait 2000", "accessibility copied"].map(String::from));
339 steps.extend(["key Down", "key Down", "key Down", "key Enter", "settle"].map(String::from));
340 steps.extend(["key Down", "key Enter", "accessibility copied"].map(String::from));
344341 steps.extend(search("Second author", "found"));
345342 let [copied, found] = run(&scratch, &notebook, &steps).try_into().unwrap();
346343 let pages = page_tabs(&copied);
......@@ -359,9 +356,9 @@ fn renaming_a_section_from_its_tab_types_into_the_sidebar_at_once() {
359356 let scratch = Scratch::new("rename-shut-sidebar");
360357 let notebook =
361358 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
362 let mut steps = vec!["move 53 53", "press right", "release right", "wait 400"];
363 steps.extend(["key Down", "key Enter", "wait 400"]);
364 steps.extend(["type Renamed", "wait 100", "accessibility typed"]);
359 let mut steps = vec!["move 53 53", "press right", "release right", "settle"];
360 steps.extend(["key Down", "key Enter", "settle"]);
361 steps.extend(["type Renamed", "accessibility typed"]);
365362 let [typed] = replay(&scratch, Some(&notebook), &steps)
366363 .try_into()
367364 .unwrap();
......@@ -378,10 +375,10 @@ fn the_find_bar_keeps_room_for_the_query() {
378375 let scratch = Scratch::new("find-room");
379376 let notebook =
380377 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
381 let mut steps = vec!["modifiers command", "key f", "modifiers", "wait 200"];
382 steps.extend(["type Alpha", "wait 400", "move 600 600", "press", "release"]);
383 steps.extend(["wait 200", "move 1000 52", "press", "release", "wait 200"]);
384 steps.extend(["type zz", "wait 200", "accessibility found"]);
378 let mut steps = vec!["modifiers command", "key f", "modifiers", "settle"];
379 steps.extend(["type Alpha", "settle", "move 600 600", "press", "release"]);
380 steps.extend(["settle", "move 1000 52", "press", "release", "settle"]);
381 steps.extend(["type zz", "accessibility found"]);
385382 let [found] = replay(&scratch, Some(&notebook), &steps)
386383 .try_into()
387384 .unwrap();
......@@ -417,8 +414,13 @@ fn a_launch_returns_to_the_page_each_notebook_was_left_on() {
417414 });
418415 std::fs::write(scratch.0.join("settings.json"), settings.to_string()).unwrap();
419416 // The other notebook's section in the sidebar.
420 let mut steps = vec!["accessibility launched", "move 63 104", "press", "release"];
421 steps.extend(["wait 4000", "accessibility other"]);
417 let steps = [
418 "accessibility launched",
419 "move 63 104",
420 "press",
421 "release",
422 "accessibility other",
423 ];
422424 let shown = |tree: &str| page_tabs(tree).into_iter().find(|tab| tab.starts_with('*'));
423425 let [launched, other] = replay(&scratch, Some(&source), &steps).try_into().unwrap();
424426 assert_eq!(