From e9315bc141d75511ef8a84531ec8f880e9ef8d97 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:09:48 +0300 Subject: [PATCH] UI: seeded whole-window soak with memory and timing bounds ui_soak_tests.rs drives the app through the egui test harness with seeded random clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the sample deck. Every ten actions it checks that every document validates, the selection is in range, undo, journal, thumbnail and texture caches stay bounded, and no command or render panic was caught; at the end, that input really reached the editor and the mean frame stays under 250 ms. Two seeds x 150 actions run in CI; DECKCRAFT_UI_SOAK_STEPS/SEED run it long. Panics swallowed by the guards were invisible to tests: Session now counts them (panics_caught) and deckcraft_render::panics_caught() counts render panics drawn as blank. Textures::thumb_count exposes the thumbnail cache size. Co-authored-by: Cursor --- crates/engine/src/guard.rs | 2 + crates/engine/src/lib.rs | 3 + crates/render/src/lib.rs | 15 ++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/textures.rs | 5 + crates/ui-egui/src/ui_soak_tests.rs | 206 ++++++++++++++++++++++++++++ docs/gaps.md | 13 +- 7 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/ui_soak_tests.rs diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 2a633ca6..8d2b7143 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -52,6 +52,7 @@ impl Session { st.revision = revision.max(st.revision).saturating_add(1); } log::error!("command `{id}` panicked: {msg}"); + self.panics_caught = self.panics_caught.saturating_add(1); if let Some(dir) = self.recovery_dir.clone() && let Err(e) = crate::recovery::save(self, &dir) { @@ -85,6 +86,7 @@ mod tests { assert!(err.to_string().contains("test.panic") && err.to_string().contains("boom"), "{err}"); let st = s.active().unwrap(); assert!(std::sync::Arc::ptr_eq(&st.doc, &before), "the document is the one from before the command"); + assert_eq!(s.panics_caught, 1, "the guard counts what it caught"); // The session keeps working. s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap(); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..08aca66b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -351,6 +351,8 @@ pub struct Session { /// ends) instead of Windows and Linux (Ctrl+arrows by word or paragraph). Defaults to the /// platform DeckCraft was built for. pub mac_keys: bool, + /// Command panics caught by the guard (the document was rolled back); soak tests require 0. + pub panics_caught: u64, } impl Default for Session { @@ -379,6 +381,7 @@ impl Session { media_status: Default::default(), gif_paused: Default::default(), mac_keys: cfg!(target_os = "macos"), + panics_caught: 0, } } /// A session with one new presentation open. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..8f04ae3b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -257,6 +257,17 @@ impl Default for Renderer { } } +static PANICS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +fn note_panic() { + PANICS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); +} + +/// Render panics caught (and drawn as blank) since start; soak tests require it to stay 0. +pub fn panics_caught() -> u64 { + PANICS.load(std::sync::atomic::Ordering::Relaxed) +} + thread_local! { static RENDERER: std::cell::RefCell = std::cell::RefCell::new(Renderer::default()); } @@ -330,6 +341,7 @@ pub fn render_slide_drawn(pres: &Presentation, index: usize, opts: &RenderOpts) } Err(_) => { log::error!("rendering slide {index} panicked; showing a blank slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); (Image { width: w as u32, height: h as u32, pixels: vec![255; w as usize * h as usize * 4] }, page) @@ -354,6 +366,7 @@ pub fn render_slide_part(pres: &Presentation, index: usize, part: SlidePart, opt Ok(img) => img, Err(_) => { log::error!("rendering part of slide {index} panicked; showing nothing"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); let (w, h) = output_size(pres, opts); Image { width: w as u32, height: h as u32, pixels: vec![0; w as usize * h as usize * 4] } @@ -385,6 +398,7 @@ pub fn render_blend( Ok(img) => img, Err(_) => { log::error!("rendering a frame between slides {old} and {new} panicked; showing the new slide"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); render_slide(pres, new, opts) } @@ -397,6 +411,7 @@ pub fn render_layout(pres: &Presentation, master: usize, layout: Option, Ok(img) => img, Err(_) => { log::error!("rendering a layout panicked; showing a blank image"); + note_panic(); RENDERER.with(|r| *r.borrow_mut() = Renderer::default()); Image::default() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e6..bf83512e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1129,6 +1129,8 @@ fn js_now() -> f64 { #[cfg(test)] mod ime_tests; +#[cfg(test)] +mod ui_soak_tests; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 40498422..02c56925 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -162,6 +162,11 @@ impl Textures { Some((tex, region)) } + /// Cached thumbnails (bounded; soak tests check it). + pub fn thumb_count(&self) -> usize { + self.thumbs.len() + } + /// Force the canvas to re-render next time (fonts loaded, theme changed in place…). pub fn invalidate(&mut self) { self.canvas = None; diff --git a/crates/ui-egui/src/ui_soak_tests.rs b/crates/ui-egui/src/ui_soak_tests.rs new file mode 100644 index 00000000..6e25c696 --- /dev/null +++ b/crates/ui-egui/src/ui_soak_tests.rs @@ -0,0 +1,206 @@ +//! UI soak: the whole window driven by seeded random input (clicks, drags, keys with and without +//! Ctrl/Shift, typing, scrolling) over the sample deck, with invariants every few actions: no +//! panic, every document valid, undo/journal/texture caches bounded, and frame time bounded. +//! +//! `DECKCRAFT_UI_SOAK_STEPS=5000 cargo test -p deckcraft-ui-egui ui_soak_long -- --ignored` +//! runs a long one; `DECKCRAFT_UI_SOAK_SEED` picks the seed. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use deckcraft_engine::Session; +use egui::{Event, Key, Modifiers, MouseWheelUnit, Pos2, vec2}; +use egui_kittest::Harness; + +use crate::{Services, SlideApp}; + +const W: f32 = 1400.0; +const H: f32 = 900.0; +/// Bounds that a long session must stay under. +const MAX_TEXTURES: usize = 1_500; +const MAX_THUMBS: usize = 600; +const MAX_JOURNAL: usize = 10_000; +/// Mean wall time per frame, generous for slow CI machines (the test profile is optimized). +const MAX_MEAN_FRAME: Duration = Duration::from_millis(250); + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + if n == 0 { 0 } else { (self.next() % n as u64) as usize } + } + fn pos(&mut self) -> Pos2 { + Pos2::new(self.below(W as usize) as f32, self.below(H as usize) as f32) + } +} + +const KEYS: &[Key] = &[ + Key::ArrowLeft, + Key::ArrowRight, + Key::ArrowUp, + Key::ArrowDown, + Key::Enter, + Key::Tab, + Key::Backspace, + Key::Delete, + Key::Escape, + Key::Home, + Key::End, + Key::PageUp, + Key::PageDown, + Key::A, + Key::C, + Key::V, + Key::X, + Key::D, + Key::G, + Key::B, + Key::I, + Key::Z, + Key::Y, + Key::M, + Key::F5, +]; + +fn harness() -> Harness<'static, SlideApp> { + let mut s = Session::new(); + deckcraft_engine::sample::open_sample(&mut s).unwrap(); + let app = SlideApp::new(s, Services::default()); + let mut h = Harness::builder().with_size(vec2(W, H)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h +} + +fn act(h: &mut Harness<'_, SlideApp>, r: &mut Rng) -> String { + match r.below(9) { + 0..=2 => { + let p = r.pos(); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + format!("click {p:?}") + } + 3 => { + let (a, b) = (r.pos(), r.pos()); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + for i in 1..=4 { + h.hover_at(a + (b - a) * (i as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + format!("drag {a:?} -> {b:?}") + } + 4 | 5 => { + let k = KEYS[r.below(KEYS.len())]; + let m = match r.below(4) { + 0 => Modifiers::COMMAND, + 1 => Modifiers::SHIFT, + _ => Modifiers::NONE, + }; + h.key_press_modifiers(m, k); + format!("key {m:?} {k:?}") + } + 6 => { + h.event(Event::Text("Soak ✓ שלום 漢字 ".into())); + "type".into() + } + 7 => { + let p = r.pos(); + h.hover_at(p); + h.event(Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, if r.below(2) == 0 { 3.0 } else { -3.0 }), + modifiers: Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + format!("scroll {p:?}") + } + _ => { + // Undo or redo through the keyboard, the way people hammer it. + let k = if r.below(2) == 0 { Key::Z } else { Key::Y }; + h.key_press_modifiers(Modifiers::COMMAND, k); + format!("ctrl {k:?}") + } + } +} + +fn check(h: &Harness<'_, SlideApp>, seed: u64, step: usize, log: &VecDeque) { + let ctx = || format!("seed {seed} step {step}; last actions:\n{}", log.iter().cloned().collect::>().join("\n")); + let app = h.state(); + for d in app.session.documents() { + let v = d.doc.validate(); + assert!(v.is_empty(), "invalid document {v:?}; {}", ctx()); + assert!(d.history.undo.len() <= d.history.limit.max(1), "undo over limit; {}", ctx()); + assert!(d.selection.slide < d.doc.slides.len().max(1), "selection slide out of range; {}", ctx()); + } + assert!(app.session.journal.len() <= MAX_JOURNAL, "journal {}; {}", app.session.journal.len(), ctx()); + assert!(app.textures.thumb_count() <= MAX_THUMBS, "thumbnails {}; {}", app.textures.thumb_count(), ctx()); + assert_eq!(app.session.panics_caught, 0, "a command panicked (rolled back by the guard); {}", ctx()); + assert_eq!(deckcraft_render::panics_caught(), 0, "a render panic was caught and drawn blank; {}", ctx()); + let tex = h.ctx.tex_manager().read().num_allocated(); + assert!(tex <= MAX_TEXTURES, "{tex} textures allocated; {}", ctx()); +} + +fn soak(seed: u64, steps: usize) { + let mut h = harness(); + let mut r = Rng(seed | 1); + let mut log = VecDeque::new(); + let t0 = Instant::now(); + let mut frames = 0usize; + for step in 0..steps { + log.push_back(act(&mut h, &mut r)); + if log.len() > 20 { + log.pop_front(); + } + h.run_steps(2); + frames += 8; + if step % 10 == 9 { + check(&h, seed, step, &log); + } + // A modal or the slide show can swallow input forever; leave it now and then. + if step % 50 == 49 { + h.key_press(Key::Escape); + h.run_steps(2); + } + } + check(&h, seed, steps, &log); + let edits = h.state().session.journal.len(); + // Random input must reach the editor, not bounce off one modal. + assert!(edits * 20 >= steps, "only {edits} commands ran in {steps} actions; seed {seed}"); + let mean = t0.elapsed() / frames.max(1) as u32; + eprintln!("ui soak seed {seed}: {steps} actions, {edits} commands, ~{frames} frames, mean frame {mean:?}"); + assert!(mean <= MAX_MEAN_FRAME, "mean frame {mean:?} over {MAX_MEAN_FRAME:?}; seed {seed}"); +} + +fn env(name: &str, default: u64) -> u64 { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +#[test] +fn ui_soak_random_input_keeps_the_app_sound() { + for seed in [3, 0xC0FFEE] { + soak(seed, env("DECKCRAFT_UI_SOAK_STEPS", 150) as usize); + } +} + +#[test] +#[ignore = "long UI soak; set DECKCRAFT_UI_SOAK_STEPS"] +fn ui_soak_long() { + soak(env("DECKCRAFT_UI_SOAK_SEED", 0x5EED), env("DECKCRAFT_UI_SOAK_STEPS", 3000) as usize); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..d6dbd3b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -48,11 +48,14 @@ revision history. - **Estimate:** 4–8 h (backend probing outside the process or a safe-mode fallback, a software renderer path). Needs a tester with the hardware. **Beta blocker.** -### No soak or long-session testing -- **Evidence:** unit tests (504), PPTX malformed/fuzz tests and the command panic guard exist; no - random command sequences over big decks, no memory or undo-depth soak. -- **Impact:** unknown crash and slowdown rate in long sessions. -- **Estimate:** 4–8 h. +### Soak testing is new +- **Evidence:** `crates/ui-egui/src/ui_soak_tests.rs` drives the whole window with seeded random + clicks, drags, keys (plain, Ctrl, Shift), typing and scrolling over the sample deck, checking + document validity, bounded undo/journal/thumbnail/texture caches, zero caught command and + render panics (`Session::panics_caught`, `deckcraft_render::panics_caught`) and a mean frame + under 250 ms; 4 × 3000-action runs are clean. Engine-level command soak: PR #502. +- **Impact:** long-session crash rate is sampled in CI; RSS is not measured directly. +- **Estimate:** 1–2 h (RSS sampling per platform). ## File formats