From aa268f0f0efac3762d482d5571829f9b6617d8d6 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:51:18 +0200 Subject: [PATCH] Faster tests: hostile_params_never_panic shares its commands across threads (154 s to 20 s), the design-file fuzz test tries its seeded cases on all cores (21 s to 7.5 s), the kernel's tangent-primitive boolean test runs its three seeds at once, and the headless scenario harness skips the software viewport render it threw away every frame (scenarios 63 s to 26 s). kill_mid_save waits for the saver to reach a varying round instead of sleeping a fixed time, so it holds on a busy machine --- apps/solvecraft-cli/tests/design_corpus.rs | 33 ++++++++--- apps/solvecraft-cli/tests/kill_mid_save.rs | 14 ++++- crates/engine/src/tests.rs | 69 +++++++++++++--------- crates/kernel/src/tests.rs | 8 ++- crates/ui-egui/src/scenario.rs | 2 + crates/ui-egui/src/viewport.rs | 5 ++ 6 files changed, 90 insertions(+), 41 deletions(-) diff --git a/apps/solvecraft-cli/tests/design_corpus.rs b/apps/solvecraft-cli/tests/design_corpus.rs index 65a87f96..7cc2fb24 100644 --- a/apps/solvecraft-cli/tests/design_corpus.rs +++ b/apps/solvecraft-cli/tests/design_corpus.rs @@ -153,7 +153,8 @@ fn try_bytes(bytes: &[u8]) { #[test] fn damaged_and_hostile_design_files_never_panic() { let mut rng = Rng(0x5eed_cafe_f00d_d00d); - let mut failures = Vec::new(); + // The cases come from one seeded sequence; trying them is independent, so threads share it. + let mut all_cases: Vec<(String, String, Vec)> = Vec::new(); for p in corpus() { let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); let bytes = std::fs::read(&p).unwrap(); @@ -198,14 +199,28 @@ fn damaged_and_hostile_design_files_never_panic() { cases.push(("features repeated".into(), v.to_string().into_bytes())); cases.push(("deep nesting".into(), format!("{}{}", "[".repeat(100_000), "]".repeat(100_000)).into_bytes())); cases.push(("binary".into(), (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 24) as u8).collect())); - for (what, b) in cases { - if std::env::var_os("CORPUS_TRACE").is_some() { - eprintln!("{name}: {what}"); - } - if std::panic::catch_unwind(|| try_bytes(&b)).is_err() { - failures.push(format!("{name}: {what}")); - } - } + all_cases.extend(cases.into_iter().map(|(what, b)| (name.clone(), what, b))); } + let next = std::sync::atomic::AtomicUsize::new(0); + let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); + let failures: Vec = std::thread::scope(|scope| { + let workers: Vec<_> = (0..threads) + .map(|_| { + scope.spawn(|| { + let mut failures = Vec::new(); + while let Some((name, what, b)) = all_cases.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) { + if std::env::var_os("CORPUS_TRACE").is_some() { + eprintln!("{name}: {what}"); + } + if std::panic::catch_unwind(|| try_bytes(b)).is_err() { + failures.push(format!("{name}: {what}")); + } + } + failures + }) + }) + .collect(); + workers.into_iter().flat_map(|w| w.join().unwrap()).collect() + }); assert!(failures.is_empty(), "panics:\n{}", failures.join("\n")); } diff --git a/apps/solvecraft-cli/tests/kill_mid_save.rs b/apps/solvecraft-cli/tests/kill_mid_save.rs index 8cc4dc7a..57f48071 100644 --- a/apps/solvecraft-cli/tests/kill_mid_save.rs +++ b/apps/solvecraft-cli/tests/kill_mid_save.rs @@ -22,10 +22,20 @@ fn killed_mid_save_the_old_file_survives() { assert_eq!(round_of(&path).as_deref(), Some("0")); let mut kills = 0; let mut seen = std::collections::BTreeSet::new(); + let modified = || std::fs::metadata(&path).and_then(|m| m.modified()).ok(); for i in 0..25u64 { + let before = modified(); let mut child = Command::new(exe).args(["save-stress", &path.to_string_lossy(), "100000"]).spawn().unwrap(); - // Let it get into its save loop (building the design takes a moment), then kill it. - std::thread::sleep(Duration::from_millis(150 + (i * 37) % 200)); + // Wait until it is in its save loop (its first save replaced the file) and has reached a + // round that varies from kill to kill, however busy the machine is; then kill it a + // varying while later. + let target = i % 10; + let reached = || modified() != before && round_of(&path).and_then(|r| r.parse::().ok()).is_some_and(|r| r >= target); + let t0 = std::time::Instant::now(); + while !reached() && t0.elapsed() < Duration::from_secs(20) && child.try_wait().unwrap().is_none() { + std::thread::sleep(Duration::from_millis(2)); + } + std::thread::sleep(Duration::from_millis((i * 7) % 30)); if child.try_wait().unwrap().is_none() { child.kill().unwrap(); kills += 1; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d7650630..0311b032 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -279,8 +279,6 @@ fn hostile_params_never_panic() { "chain", "at", "curve", - "major", - "minor", "minor_radius", "apex", "rho", @@ -298,32 +296,47 @@ fn hostile_params_never_panic() { "kind", "prefix", ]; - let mut internal = Vec::new(); - for spec in command_specs() { - if matches!(spec.id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf") { - continue; // file system side effects are covered by their own tests - } - for v in hostile_values() { - for k in keys { - if k == "path" && spec.id.starts_with("parameters.") { - continue; // parameter files: covered by their own tests (no stray files) - } - let mut s = Session::default(); - // A model with a sketch, a body and an active sketch, so most commands get far. - let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})); - let _ = s.execute("sketch.create", &json!({"plane": "XY"})); - let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]})); - let p = json!({ k: v.clone() }); - if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) { - internal.push(format!("{id} {p}: {msg}")); - } - // Whole-value hostility too. - if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) { - internal.push(format!("{id} {v}: {msg}")); - } - } - } - } + // Commands are independent; worker threads take them one at a time (their costs vary a lot). + let ids: Vec<&str> = command_specs() + .iter() + .map(|spec| spec.id) + .filter(|id| !matches!(*id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh" | "sketch.export_dxf")) // file system side effects are covered by their own tests + .collect(); + let next = std::sync::atomic::AtomicUsize::new(0); + let threads = std::thread::available_parallelism().map_or(4, |n| n.get()); + let internal: Vec = std::thread::scope(|scope| { + let workers: Vec<_> = (0..threads) + .map(|_| { + scope.spawn(|| { + let mut internal = Vec::new(); + while let Some(&id) = ids.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) { + for v in hostile_values() { + for k in keys { + if k == "path" && id.starts_with("parameters.") { + continue; // parameter files: covered by their own tests (no stray files) + } + let mut s = Session::default(); + // A model with a sketch, a body and an active sketch, so most commands get far. + let _ = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})); + let _ = s.execute("sketch.create", &json!({"plane": "XY"})); + let _ = s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]})); + let p = json!({ k: v.clone() }); + if let Err(EngineError::Internal(id, msg)) = s.execute(id, &p) { + internal.push(format!("{id} {p}: {msg}")); + } + // Whole-value hostility too. + if let Err(EngineError::Internal(id, msg)) = s.execute(id, &v) { + internal.push(format!("{id} {v}: {msg}")); + } + } + } + } + internal + }) + }) + .collect(); + workers.into_iter().flat_map(|w| w.join().unwrap()).collect() + }); assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n")); } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 93afea8c..28e53b71 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1361,8 +1361,12 @@ fn partial_revolve_on_the_axis() { /// exactly where the other body cut them). #[test] fn boolean_identity_on_tangent_primitives() { - for seed in [1, 32, 169] { - let c = crate::fuzz::boolean_case(seed); + // Independent seeds, one thread each. + let cases: Vec<_> = std::thread::scope(|scope| { + let workers = [1, 32, 169].map(|seed| scope.spawn(move || (seed, crate::fuzz::boolean_case(seed)))); + workers.into_iter().map(|w| w.join().unwrap()).collect() + }); + for (seed, c) in cases { assert_eq!(c.outcome, "ok", "seed {seed}: {} {:?} {:?}", c.detail, c.a, c.b); } } diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index fb267f6e..6d0d9f6d 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -170,6 +170,8 @@ impl Harness { /// File pickers answer like a person would: save into the scenario's folder, open the file /// the scenario chose. fn install(&mut self) { + // Frames are laid out, never painted. + self.app.viewport.no_pixels = true; let dir = self.dir.clone(); self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned()))); let next = self.next_file.clone(); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index e9eff948..e8e375b0 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -35,6 +35,8 @@ pub struct ViewportState { hl_slot: SceneSlot, hl_key: u64, cpu: Option<(u64, egui::TextureHandle)>, + /// Nothing will look at the frame (the headless scenario harness): skip the software render. + pub no_pixels: bool, pub nav: Option, /// What a click would pick now (after the active command's filter). pub hover: Option, @@ -1927,6 +1929,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) { + if app.viewport.no_pixels { + return; + } let mut h = std::collections::hash_map::DefaultHasher::new(); scene_key(app).hash(&mut h); for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] {