UI tests: wait for the sidebar's file-system checks before clicking

keyword_list_filters_renames_and_suggests failed intermittently under
full-suite load (1 photo instead of 2 after clicking travel|italy, or no
travel|italy row at all). No metadata was lost: at the failure every
photo still had its keywords, but the filter was `travel|france`.

Local's rows (Downloads, Home, ...) are added only once their is_dir
checks come back from fs_cached worker threads. Those start when the left
panel is first drawn, which in this test is right before the clicks.
Headless::busy() did not count them. Under load a row could land between
the frame that read a widget's rect and the frame that delivered the
press. The keyword rows then moved down 29 px and the click hit the row
above (travel|france), or the toggle click missed travel. Diagnostics
showed the click aimed at y=1307 and travel|italy drawn at 1365 after
the two Local rows landed. The same failure reproduces on 5cee0c0 (2 of
20 runs), so the thumbnail work did not cause it.

fs_cached now keeps a per-context count of checks in flight
(fs_cached_running), and busy() includes it, so settle() waits for them.
The keyword test settles after opening the left panel. The new test
settle_waits_for_file_system_checks holds a check open and fails without
the busy() change.

Full ui-egui lib runs with 4 test threads: 2 failures in 25 before the
change, none in the runs after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
radialmonster 2026-10-07 10:10:35 -04:00
parent b4dc8c10ee
commit e9931d6871
2 changed files with 54 additions and 2 deletions

View File

@ -169,9 +169,11 @@ impl Headless {
}
}
/// Is anything still in progress (renders, queued input)?
/// Is anything still in progress (renders, file-system checks the sidebar waits for, queued
/// input)?
pub fn busy(&self) -> bool {
self.app.renderer.in_flight() > 0
|| crate::panels::left::fs_cached_running(&self.view.ctx) > 0
|| self.app.merge.busy()
|| self.app.scan.is_some()
|| self.app.import.is_some()
@ -534,6 +536,9 @@ mod tests {
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]}));
ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]}));
h.request("ui.set", json!({"leftPanel": true}), t);
// Local's rows (Downloads, Home, …) appear when their background checks land and push
// the keyword rows down: clicking before that hit the row above (`travel|france`)
h.settle(SETTLE);
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel"));
@ -560,6 +565,36 @@ mod tests {
h.settle(SETTLE);
}
/// The sidebar's file-system checks run on worker threads and add rows when they land, which
/// moves every row below them: `busy()` counts them, so `settle` waits for them before a test
/// reads widget positions (a click aimed at a stale rect hits the neighbouring row).
#[test]
fn settle_waits_for_file_system_checks() {
use std::sync::atomic::{AtomicBool, Ordering};
static RELEASE: AtomicBool = AtomicBool::new(false);
fn slow_check(_: &str) -> bool {
let t0 = Instant::now();
while !RELEASE.load(Ordering::Acquire) && t0.elapsed() < Duration::from_secs(60) {
std::thread::sleep(Duration::from_millis(1));
}
true
}
let mut h = demo([800.0, 600.0]);
h.settle(SETTLE);
let check = |h: &mut Headless| {
let raw = HeadlessView::raw_input(h.size, 1.0, 0.0, vec![]);
let mut got = None;
h.view.run(raw, |ui| got = crate::panels::left::fs_cached(ui, "test-slow", "x", f64::INFINITY, slow_check));
got
};
assert_eq!(check(&mut h), None, "the answer is worked out off the UI thread");
assert!(h.busy(), "a pending file-system check is pending work");
RELEASE.store(true, Ordering::Release);
assert!(h.settle(SETTLE));
assert!(!h.busy());
assert_eq!(check(&mut h), Some(true));
}
/// Photo > Rename Photos…: the dialog previews and renames the selection.
#[test]
fn rename_dialog_renames_the_selection() {

View File

@ -335,6 +335,17 @@ fn list_subfolders(path: &str) -> Vec<(String, String)> {
v
}
/// How many [`fs_cached`] answers for `ctx` are being worked out right now. Rows appear (and
/// the sidebar below them moves) when they land, so the headless driver counts them as pending
/// work and waits for them before acting on widget positions.
pub(crate) fn fs_cached_running(ctx: &egui::Context) -> usize {
fs_running_counter(ctx).load(std::sync::atomic::Ordering::Acquire)
}
fn fs_running_counter(ctx: &egui::Context) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
ctx.data_mut(|d| d.get_temp_mut_or_default::<std::sync::Arc<std::sync::atomic::AtomicUsize>>(egui::Id::new("fs-cached-running")).clone())
}
/// A file-system answer for `path` (`f(path)`), kept per `kind` and path and refreshed on a worker
/// thread at most every `every` seconds: a folder on a sleeping NAS, a dropped share or a
/// spinning-up drive never blocks a frame. `None` until the first answer arrives.
@ -366,18 +377,24 @@ pub(crate) fn fs_cached<T: Clone + Send + 'static>(ui: &egui::Ui, kind: &'static
due
};
if start {
let (out, path, repaint) = (cell.clone(), path.to_string(), ui.ctx().clone());
use std::sync::atomic::Ordering;
let running = fs_running_counter(ui.ctx());
running.fetch_add(1, Ordering::AcqRel);
let (out, path, repaint, done) = (cell.clone(), path.to_string(), ui.ctx().clone(), running.clone());
let work = move || {
let v = f(&path);
let mut e = out.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
e.value = Some(v);
e.running = false;
drop(e);
// after the answer is stored: a frame that sees the count drop also sees the answer
done.fetch_sub(1, Ordering::AcqRel);
repaint.request_repaint();
};
#[cfg(not(target_arch = "wasm32"))]
if std::thread::Builder::new().name("lc-fs-list".into()).spawn(work).is_err() {
cell.lock().unwrap_or_else(std::sync::PoisonError::into_inner).running = false;
running.fetch_sub(1, Ordering::AcqRel);
}
#[cfg(target_arch = "wasm32")]
work();