From 1b1378b05ab25d14b3ac40e6e957818b9fa21be6 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 00:06:54 -0400 Subject: [PATCH] Branch on a measurement, so a wrong one shows as a different tree Comparing boxes catches a widget that moved. It does not catch one that measured a child, was handed an answer a cold start would not have given, and took the other branch -- the same defect, arriving where a pixel comparison cannot see it. Branching on what the painter tells you is something a widget is allowed to do, so the library owes the same answer warm and cold; only a widget changing its own configuration is exempt. `random::Branch` measures a child and draws one of two others on the result, with both grown either way so the ids match whichever is drawn. It joins the generator, which makes every existing scenario a control-flow oracle as well as a geometric one. `tests/determinism.rs` is the same widget by hand across eight thresholds, including either side of the answer, and is the fast check -- the sweep is a fuzzer and confirms at the end rather than being iterated against. A span behind a branch nobody took is not drawn, so shuffling it cannot move anything; `reshuffled` now treats that as vacuous, the way it already treats a tree with no spans, rather than as a shuffle that had no effect. Both new tests pass, and the sweep passes at depth 4 and 5 over 200 seeds. Co-Authored-By: Claude Opus 5 --- src/random.rs | 46 ++++++++++++++++++++ tests/determinism.rs | 101 +++++++++++++++++++++++++++++++++++++++++++ tests/generated.rs | 11 ++++- 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 tests/determinism.rs diff --git a/src/random.rs b/src/random.rs index 30700bd..ae3ad94 100644 --- a/src/random.rs +++ b/src/random.rs @@ -84,6 +84,35 @@ pub struct Tree { pub detached: Vec, } +/// Branches on a child's measured length. Comparing boxes catches a widget +/// that moved; this catches one that believed a measurement a cold start +/// would not have given it, by turning that into a different tree. Its own +/// configuration never changes, so which side draws is a property of the +/// layout alone. +pub struct Branch { + pub probe: StrongWidget, + pub wide: StrongWidget, + pub narrow: StrongWidget, + pub threshold: f32, +} + +impl Widget for Branch { + fn draw(&mut self, painter: &mut Painter) -> Size { + let mut top = UiRegion::FULL; + top.y.end = top.y.start.offset(40.0); + let measured = painter.place(&self.probe, top).len(Axis::X); + let px = measured.apply_rest().to_px(painter.px_len(Axis::X)); + + let mut rest = UiRegion::FULL; + rest.y.start = rest.y.start.offset(40.0); + match px > self.threshold { + true => painter.place(&self.wide, rest), + false => painter.place(&self.narrow, rest), + }; + Size::REST + } +} + pub struct Spanned { pub id: WeakWidget, /// Leaves grown with the span whether or not they end up in it, so both @@ -199,6 +228,23 @@ impl Grow<'_, Rsc> { self.tree.ids.push(id.id()); return id.add_strong(self.rsc); } + if positioned == 2 { + // Both sides are grown either way, so a tree that draws one has + // the same ids as a tree that draws the other. + let probe = self.node(depth - 1); + let wide = self.node(depth - 1); + let narrow = self.node(depth - 1); + let threshold = self.rng.below(500) as f32; + let id = Branch { + probe, + wide, + narrow, + threshold, + } + .add(self.rsc); + self.tree.ids.push(id.id()); + return id.add_strong(self.rsc); + } if positioned == 1 { let inner = self.node(depth - 1); let inner = self.sized(inner); diff --git a/tests/determinism.rs b/tests/determinism.rs new file mode 100644 index 0000000..da9ddf6 --- /dev/null +++ b/tests/determinism.rs @@ -0,0 +1,101 @@ +//! A measurement that decides control flow. +//! +//! Comparing boxes catches a widget that moved. It does not catch a widget +//! that measured a child, believed a different answer from the one a cold +//! start would give, and took the other branch -- which is the same defect +//! arriving somewhere it cannot be ignored. A widget here branches on what it +//! measured, so a disagreement shows up as a different tree. + +use iris::harness::Harness; +use iris::prelude::*; + +/// Measures `probe` across `axis` and draws one of two children on the +/// answer. Its own configuration never changes, so which child is drawn is a +/// property of the layout alone. +struct BranchesOnMeasurement { + probe: StrongWidget, + wide: StrongWidget, + narrow: StrongWidget, + threshold: f32, +} + +impl Widget for BranchesOnMeasurement { + fn draw(&mut self, painter: &mut Painter) -> Size { + let mut top = UiRegion::FULL; + top.y.end = top.y.start.offset(40.0); + let measured = painter.place(&self.probe, top).len(Axis::X); + let px = measured.apply_rest().to_px(painter.px_len(Axis::X)); + + let mut rest = UiRegion::FULL; + rest.y.start = rest.y.start.offset(40.0); + match px > self.threshold { + true => painter.place(&self.wide, rest), + false => painter.place(&self.narrow, rest), + }; + Size::REST + } +} + +fn plant(h: &mut Harness, threshold: f32) -> (WidgetId, WidgetId) { + let words = "the quick brown fox jumps over the lazy dog and keeps running"; + let probe = wtext(words).size(16).wrap(true).add(&mut h.rsc); + let wide = rect(Color::RED).add(&mut h.rsc); + let narrow = rect(Color::BLUE).add(&mut h.rsc); + let branch = BranchesOnMeasurement { + probe: probe.add_strong(&mut h.rsc), + wide: wide.add_strong(&mut h.rsc), + narrow: narrow.add_strong(&mut h.rsc), + threshold, + } + .add(&mut h.rsc); + let side = rect(Color::GREEN).width(120).add(&mut h.rsc); + h.set_root((side, branch).span(Dir::RIGHT)); + (wide.id(), narrow.id()) +} + +/// Which of the two branches drew, as a pair a test can compare. +fn taken(h: &Harness, wide: WidgetId, narrow: WidgetId) -> (bool, bool) { + (h.region(&wide).is_some(), h.region(&narrow).is_some()) +} + +#[test] +fn a_branch_taken_on_a_measurement_holds_across_repaints() { + for threshold in [0.0, 200.0, 400.0, 600.0, 779.0, 780.0, 781.0, 2000.0] { + let mut h = Harness::new((900, 600)); + let (wide, narrow) = plant(&mut h, threshold); + let first = taken(&h, wide, narrow); + assert_ne!(first, (false, false), "threshold {threshold}: neither drew"); + + for frame in 0..4 { + h.rsc.widgets_mut().get_dyn_mut(wide); + h.rsc.widgets_mut().get_dyn_mut(narrow); + h.frame(); + assert_eq!( + taken(&h, wide, narrow), + first, + "threshold {threshold}, repaint {frame}: the branch moved when nothing did" + ); + } + } +} + +#[test] +fn a_branch_taken_on_a_measurement_is_the_one_a_cold_start_takes() { + for threshold in [0.0, 200.0, 400.0, 600.0, 779.0, 780.0, 781.0, 2000.0] { + let mut warm = Harness::new((900, 600)); + let (wide, narrow) = plant(&mut warm, threshold); + warm.resize((640, 480)); + warm.frame(); + warm.rsc.widgets_mut().get_dyn_mut(wide); + warm.frame(); + + let mut cold = Harness::new((640, 480)); + let (cwide, cnarrow) = plant(&mut cold, threshold); + + assert_eq!( + taken(&warm, wide, narrow), + taken(&cold, cwide, cnarrow), + "threshold {threshold}: warm and cold took different branches" + ); + } +} diff --git a/tests/generated.rs b/tests/generated.rs index 124018d..e77f99e 100644 --- a/tests/generated.rs +++ b/tests/generated.rs @@ -224,8 +224,15 @@ fn reshuffled(seed: u64, shuffle: Shuffle) { let mut warm = Harness::new((900, 1200)); let mut grown = plant(&mut warm, seed, &Edits::default()); // Some seeds grow nothing but wrappers, and a shuffle with no span to - // shuffle is not the same thing as one that had no effect. - if grown.spans.is_empty() { + // shuffle is not the same thing as one that had no effect. A span behind + // a branch nobody took is the same kind of nothing: it is not drawn, so + // shuffling it cannot move anything. + let shuffles = grown + .spans + .iter() + .step_by(3) + .any(|span| warm.region(&span.id.id()).is_some()); + if !shuffles { return; } let before: Vec<_> = grown.ids.iter().map(|id| warm.region(id)).collect();