From 85833cf5f53969494cc8a925561a2861f0aabc1f Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 21:50:23 +0300 Subject: [PATCH] Engine: seeded editor soak tests; tiny charts no longer blank the slide soak_tests.rs drives every non-file command with plausible and hostile parameters over a ~100-slide deck, checking after each step that the document validates, the selection is in range, the undo stack is bounded, undo then redo restores the exact document, and every 100 steps that the deck saves and reopens as .deckcraft and .pptx. Four seeds x 400 steps run in CI (under a second); DECKCRAFT_SOAK_STEPS/SEED run it long. The long soak found that a chart a few points wide panicked in f64::clamp (label gutter min above max), and the render guard then blanked the whole slide. chart.rs now uses a non-panicking clamp_soft for the gutters and the zero line; a render test draws tiny charts next to a shape without the guard. Co-authored-by: Cursor --- crates/engine/src/lib.rs | 2 + crates/engine/src/soak_tests.rs | 179 ++++++++++++++++++++++++++++++++ crates/render/src/chart.rs | 13 ++- crates/render/src/tests.rs | 26 +++++ docs/gaps.md | 12 ++- 5 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 crates/engine/src/soak_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..d6f3f42a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -551,6 +551,8 @@ pub(crate) fn push_undo(st: &mut DocState, e: HistoryEntry) { } } +#[cfg(test)] +mod soak_tests; #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/engine/src/soak_tests.rs b/crates/engine/src/soak_tests.rs new file mode 100644 index 00000000..9c70d155 --- /dev/null +++ b/crates/engine/src/soak_tests.rs @@ -0,0 +1,179 @@ +//! Editor soak: long random command sequences over a big deck, with invariants checked after +//! every step: no panic, a valid document, an in-range selection, a bounded undo stack, undo then +//! redo restoring the exact document, and periodic save/reopen in both formats. +//! +//! Seeded and deterministic. A failure prints the seed and the last commands so it replays. +//! `DECKCRAFT_SOAK_STEPS=50000 cargo test -p deckcraft-engine soak -- --ignored` runs a long soak. + +use std::collections::VecDeque; + +use serde_json::{Value, json}; + +use crate::*; + +/// Commands that touch the file system, other windows or real time; the soak drives the rest. +fn skipped(id: &str) -> bool { + matches!(id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.export" | "file.saveTemplate" | "file.openBytes" | "file.new") + || id.starts_with("window.") + || id.starts_with("show.") + || id.starts_with("media.") +} + +/// xorshift64*: tiny, deterministic, no dependency. +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 f(&mut self, lo: f64, hi: f64) -> f64 { + lo + (self.next() >> 11) as f64 / (1u64 << 53) as f64 * (hi - lo) + } + fn pick<'a, T>(&mut self, v: &'a [T]) -> Option<&'a T> { + v.get(self.below(v.len())) + } +} + +fn shape_ids(s: &Session) -> Vec { + s.active().map(|d| d.shapes().iter().map(|x| u64::from(x.id.0)).collect()).unwrap_or_default() +} + +/// Plausible parameters built from the live document, plus the odd hostile value. +fn params(r: &mut Rng, s: &Session) -> Value { + let ids = shape_ids(s); + let id = r.pick(&ids).copied().unwrap_or(1); + let slides = s.active().map(|d| d.doc.slides.len()).unwrap_or(1); + let index = r.below(slides + 2); + match r.below(10) { + 0 => json!({}), + 1 => json!({"ids": [id]}), + 2 => json!({"id": id, "text": "Soak ✓ שלום 漢字", "name": "S"}), + 3 => json!({"index": index, "to": r.below(slides + 1)}), + 4 => json!({"preset": "rect", "rect": [r.f(0.0, 600.0), r.f(0.0, 400.0), r.f(1.0, 300.0), r.f(1.0, 200.0)]}), + 5 => json!({"ids": [id], "color": "#3366CC", "size": r.f(1.0, 96.0), "value": r.f(-10.0, 10.0)}), + 6 => json!({"ids": ids.iter().take(3).collect::>()}), + 7 => json!({"effect": "fade", "start": "afterPrevious", "duration": r.below(5000), "delay": r.below(2000)}), + 8 => json!({"layout": "titleAndContent", "title": "T", "body": "B"}), + _ => json!({"index": u64::MAX, "ids": [u64::MAX], "id": -1, "size": 1e308, "rect": [f64::MAX, 0, -1, 0]}), + } +} + +fn doc(s: &Session) -> Option { + s.active().map(|d| (*d.doc).clone()) +} + +fn check(s: &Session, seed: u64, step: usize, log: &VecDeque) { + let ctx = || format!("seed {seed} step {step}; last commands:\n{}", log.iter().cloned().collect::>().join("\n")); + let Some(d) = s.active() else { panic!("no active document; {}", ctx()) }; + let problems = d.doc.validate(); + assert!(problems.is_empty(), "{problems:?}; {}", ctx()); + assert!(d.selection.slide < d.doc.slides.len().max(1), "selection slide {} of {}; {}", d.selection.slide, d.doc.slides.len(), ctx()); + assert!(d.history.undo.len() <= d.history.limit.max(1), "undo stack {} over limit; {}", d.history.undo.len(), ctx()); +} + +fn soak(seed: u64, steps: usize) { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + // A big deck: the sample several times over. + for _ in 0..3 { + let n = s.doc().unwrap().doc.slides.len(); + for i in 0..n { + let _ = s.execute("slide.duplicate", &json!({"index": i})); + } + } + let ids: Vec<&'static str> = command_specs().iter().map(|c| c.id).filter(|id| !skipped(id)).collect(); + let mut r = Rng(seed | 1); + let mut log: VecDeque = VecDeque::new(); + let mut edits = 0usize; + for step in 0..steps { + let id = *r.pick(&ids).unwrap(); + let p = params(&mut r, &s); + log.push_back(format!("{id} {p}")); + if log.len() > 20 { + log.pop_front(); + } + let before = doc(&s); + let undoable = command_specs().iter().any(|c| c.id == id && c.undoable); + let res = s.execute(id, &p); + if let Err(EngineError::Internal(..)) = &res { + panic!("command panicked (caught by the guard): {res:?}; seed {seed} step {step}; {id} {p}"); + } + let ok = res.is_ok(); + check(&s, seed, step, &log); + // Undo then redo of a real edit gets the same document back. + let after = doc(&s); + if before != after { + edits += 1; + } + if ok && undoable && before != after && r.below(4) == 0 { + s.execute("edit.undo", &json!({})).unwrap_or_else(|e| panic!("undo failed: {e}; seed {seed} step {step} {id} {p}")); + assert!(doc(&s) == before, "undo didn't restore; seed {seed} step {step} {id} {p}"); + s.execute("edit.redo", &json!({})).unwrap_or_else(|e| panic!("redo failed: {e}; seed {seed} step {step} {id} {p}")); + assert!(doc(&s) == after, "redo didn't reapply; seed {seed} step {step} {id} {p}"); + } + // Keep the deck from vanishing or exploding. + let n = s.doc().map(|d| d.doc.slides.len()).unwrap_or(0); + if n == 0 || s.documents().len() != 1 { + panic!("documents {} slides {n}; seed {seed} step {step}; {log:?}", s.documents().len()); + } + if step % 100 == 99 { + for format in ["deckcraft", "pptx"] { + let data = + s.execute("file.saveBytes", &json!({"format": format})).unwrap_or_else(|e| panic!("save {format}: {e}; seed {seed} step {step}")); + let b64 = data["data"].as_str().unwrap_or_default().to_string(); + s.execute("file.openBytes", &json!({"name": format!("soak.{format}"), "data": b64})) + .unwrap_or_else(|e| panic!("reopen {format}: {e}; seed {seed} step {step}; {log:?}")); + let back = s.doc().unwrap().doc.slides.len(); + assert_eq!(back, n, "{format} reopen slide count; seed {seed} step {step}"); + assert!(s.doc().unwrap().doc.validate().is_empty(), "{format} reopen invalid; seed {seed} step {step}"); + let last = s.documents().len() - 1; + s.close_document(last); + s.set_active(0); + } + } + } + // The soak must really edit, not just bounce off disabled commands. + assert!(edits * 10 >= steps, "only {edits} of {steps} steps changed the document; seed {seed}"); + eprintln!("soak seed {seed}: {edits} of {steps} steps edited"); +} + +fn steps(default: usize) -> usize { + std::env::var("DECKCRAFT_SOAK_STEPS").ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +#[test] +fn soak_random_edits_keep_the_document_valid() { + for seed in [1, 0xDEC0_C2AF, 42, 7_777_777] { + soak(seed, steps(400)); + } +} + +#[test] +#[ignore = "long soak; set DECKCRAFT_SOAK_STEPS"] +fn soak_long() { + let seed = std::env::var("DECKCRAFT_SOAK_SEED").ok().and_then(|v| v.parse().ok()).unwrap_or(0x5EED); + soak(seed, steps(20_000)); +} + +#[test] +fn undo_stack_stays_bounded_and_drains() { + let mut s = Session::with_new(); + let limit = s.doc().unwrap().history.limit; + for i in 0..limit + 50 { + s.execute("shape.insert", &json!({"preset": "rect", "rect": [i % 500, 10, 20, 20]})).unwrap(); + } + assert_eq!(s.doc().unwrap().history.undo.len(), limit); + let mut undone = 0; + while s.execute("edit.undo", &json!({})).is_ok() { + undone += 1; + assert!(undone <= limit, "undo ran past the stack"); + } + assert_eq!(undone, limit); + assert!(s.doc().unwrap().doc.validate().is_empty()); +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..07fcc5c9 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -264,9 +264,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } else if c.y_hidden { base * 0.5 } else { - label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3) + clamp_soft(label_w(false, (min, max, step)), base * 1.5, area.width() * 0.3) }; - let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0); + let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| clamp_soft(label_w(true, sc), base * 1.5, area.width() * 0.3)).unwrap_or(0.0); let plot = if horiz { Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6) } else { @@ -378,7 +378,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(0.0f64.clamp(min, max)); + let zero = to_v(clamp_soft(0.0, min, max)); if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); @@ -526,6 +526,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.stroke_path(&ax); } +/// `v` kept within `lo..=hi` without `f64::clamp`'s panic when `lo > hi` (a chart smaller than +/// its label font) or a bound is NaN: the upper bound wins. +pub(crate) fn clamp_soft(v: f64, lo: f64, hi: f64) -> f64 { + let v = if v < lo { lo } else { v }; + if v > hi { hi } else { v } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..5b4f3d6f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,29 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn tiny_chart_does_not_blank_the_slide() { + // A chart a few points wide used to panic in `f64::clamp` (gutter min above max), and the + // panic guard then blanked the whole slide. Render directly, without the guard. + let red = Shape { + id: ShapeId(800), + xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), + style: Some(ShapeStyle::accent(deckcraft_color::SchemeSlot::Accent1)), + ..Default::default() + }; + let mut shapes = vec![red]; + for (i, k) in [ChartType::Column, ChartType::Bar, ChartType::Line, ChartType::Combo, ChartType::Scatter].iter().enumerate() { + shapes.push(Shape { + id: ShapeId(801 + i as u32), + xfrm: Some(Xfrm::new(500.0 + i as f64 * 5.0, 300.0, 0.9, 0.9)), + kind: ShapeKind::Chart(Box::new(Chart::sample(*k))), + ..Default::default() + }); + } + let p = deck_with(shapes); + let img = RENDERER.with(|r| r.borrow_mut().slide(&p, &p.slides[0], 0, &RenderOpts { scale: 1.0, ..Default::default() })); + let px = img.pixel(200, 150); + assert_ne!(px, [255, 255, 255, 255], "the other shape must still draw"); + assert_eq!(crate::chart::clamp_soft(5.0, 10.5, 0.276), 0.276); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..2f9a43bd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -48,11 +48,13 @@ 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 engine-only +- **Evidence:** `crates/engine/src/soak_tests.rs` runs seeded random command sequences over a + ~100-slide deck (validity, selection, undo/redo exactness, bounded undo, save/reopen in both + formats every 100 steps); `DECKCRAFT_SOAK_STEPS` runs it long. It found a chart `clamp` panic + that blanked whole slides. Still missing: UI-level (kittest) soak, memory and timing budgets. +- **Impact:** long-session crash rate is now sampled in CI; slowdowns are not measured. +- **Estimate:** 2–4 h left. ## File formats