diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f4410427..4f6f17ad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -580,6 +580,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 954ba641..6ae95372 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -260,9 +260,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 { @@ -379,7 +379,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(); @@ -527,6 +527,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 acf92a98..faf7dffe 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1792,3 +1792,29 @@ fn metafile_intersect_clip_rect_hides_the_rest_of_the_picture() { assert!(opaque(20, 25), "the clipped half is drawn"); assert!(!opaque(80, 25), "past the clip the picture stays clear, pixel {:?}", img.get_pixel(80, 25)); } + +#[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 d37f38ea..5c1ec9f8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -53,11 +53,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