From b7caab3b9e6d249ec86023e4a20b26d06e20e385 Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Tue, 15 Sep 2026 00:49:00 -0400 Subject: [PATCH] Grow trees that can be taken apart, and find that a first frame is wrong Reconstructing a generated failure by hand had failed three times: a seed reproduces a tree of hundreds of widgets, and the printed chain is not enough to see which part matters. `tests/shrink.rs` grows trees from a description it can simplify -- drop a child, unwrap a wrapper, shorten a text, drop a declared length -- and takes the first simplification that still fails until none does. It lives in the tests; nothing in the library knows about it. It works: with the box-length check in `try_reuse` deliberately disabled it reduced a 96-widget tree to 2. That check is worth keeping, because a fuzzer that cannot fail is a fuzzer that agrees with everything. What it found is not what any of this was looking for. Six widgets, shrunk from 402: Span[ Stack[ Text("Wrapping"), Aligned(pos,pos, SetSize(x: 76px, Text("Wrapping shapes", wrap))) ] ] The wrapping text is one line on the first frame and two after a repaint, and two is right for a 76px box -- so the *cold* tree is the one that has not settled. `generated.rs` has been comparing a warm frame against a cold one and calling the difference a retained-layout defect, while at least some of it is the first frame shaping a text at a width it was measured in rather than the one it was given. Retained state is not involved. `tests/unsettled.rs` is that case by hand, in 0.06s. Both of its tests fail, so both are ignored with the reason rather than left to break the build. Co-Authored-By: Claude Opus 5 --- tests/shrink.rs | 465 +++++++++++++++++++++++++++++++++++++++++++++ tests/unsettled.rs | 99 ++++++++++ 2 files changed, 564 insertions(+) create mode 100644 tests/shrink.rs create mode 100644 tests/unsettled.rs diff --git a/tests/shrink.rs b/tests/shrink.rs new file mode 100644 index 0000000..3c64258 --- /dev/null +++ b/tests/shrink.rs @@ -0,0 +1,465 @@ +//! A property test that shrinks its own counterexample. +//! +//! `generated.rs` reproduces a failure from a seed, but a seed is not a lead +//! anybody can read: the tree is hundreds of widgets, and reconstructing the +//! part that matters by hand has failed every time it has been tried. This +//! grows trees it can take apart, so a failure is reduced to the smallest +//! tree that still shows it and printed as something to write a fast test +//! from. +//! +//! cargo test --release --test shrink -- --ignored --nocapture +//! +//! `SHRINK_SEEDS` how many trees to try, `SHRINK_DEPTH` how deep to grow +//! them, `SHRINK_CASE` which scenario. It is a fuzzer: run it once the +//! ordinary tests pass, and turn what it finds into a test of its own rather +//! than leaving a seed as the record. + +use iris::harness::Harness; +use iris::prelude::*; +use iris::random::{Branch, Rng}; + +/// The same two leaves `iris::random` grows, since only one of them reads the +/// width it is given and that is the difference that matters. +const WORDS: &[&str] = &[ + "Wrapping", + "shapes", + "one", + "source", + "into", + "as", + "many", + "lines", + "as", + "the", + "box", + "leaves", + "room", + "for,", + "so", + "a", + "paragraph's", + "height", + "is", + "an", + "answer", + "and", + "not", + "a", + "setting.", +]; + +const ONE_LINE: &str = "one line, overflowing whatever it is given"; + +const OUTER: (f32, f32) = (1920.0, 1200.0); +const INNER: (f32, f32) = (640.0, 900.0); + +#[derive(Clone, Debug, PartialEq)] +enum Node { + /// Words taken from [`WORDS`], and whether it wraps. + Text(usize, bool), + /// The leaf that overflows whatever box it is given rather than wrapping. + OneLine, + Rect, + Span(bool, f32, Vec), + Stack(Vec), + Pad(f32, Box), + Aligned(u8, u8, Box), + Sized(Option, Option, Box), + Scroll(bool, Box), + Branch(Box, Box, Box, f32), +} + +fn axis_align(v: u8) -> Option { + match v % 4 { + 0 => None, + 1 => Some(AxisAlign::Neg), + 2 => Some(AxisAlign::Center), + _ => Some(AxisAlign::Pos), + } +} + +fn dir(down: bool) -> Dir { + if down { Dir::DOWN } else { Dir::RIGHT } +} + +impl Node { + /// Builds into `h`, pushing every id in tree order, so two builds of one + /// node line up index for index and their boxes can be compared. + fn build(&self, h: &mut Harness, out: &mut Vec) -> StrongWidget { + let id: StrongWidget = match self { + Node::Text(words, wrap) => { + let n = (*words).clamp(1, WORDS.len()); + wtext(WORDS[..n].join(" ")) + .size(16) + .wrap(*wrap) + .add_strong(&mut h.rsc) + } + Node::OneLine => wtext(ONE_LINE).size(16).wrap(false).add_strong(&mut h.rsc), + Node::Rect => rect(Color::RED).add_strong(&mut h.rsc), + Node::Span(down, gap, kids) => { + let children = kids.iter().map(|k| k.build(h, out)).collect(); + Span { + children, + dir: dir(*down), + gap: *gap, + } + .add_strong(&mut h.rsc) + } + Node::Stack(kids) => { + let children = kids.iter().map(|k| k.build(h, out)).collect(); + Stack { + children, + size: StackSize::Child(0), + } + .add_strong(&mut h.rsc) + } + Node::Pad(p, kid) => { + let inner = kid.build(h, out); + Pad { + padding: Padding { + left: *p, + right: *p, + top: *p, + bottom: *p, + }, + inner, + } + .add_strong(&mut h.rsc) + } + Node::Aligned(x, y, kid) => { + let inner = kid.build(h, out); + Aligned { + inner, + align: Align { + x: axis_align(*x), + y: axis_align(*y), + }, + } + .add_strong(&mut h.rsc) + } + Node::Sized(x, y, kid) => { + let inner = kid.build(h, out); + SetSize { + inner, + x: *x, + y: *y, + } + .add_strong(&mut h.rsc) + } + Node::Scroll(down, kid) => { + let inner = kid.build(h, out); + let axis = if *down { Axis::Y } else { Axis::X }; + Scroll::new(inner, axis).add_strong(&mut h.rsc) + } + Node::Branch(probe, a, b, at) => { + let probe = probe.build(h, out); + let wide = a.build(h, out); + let narrow = b.build(h, out); + Branch { + probe, + wide, + narrow, + threshold: *at, + } + .add_strong(&mut h.rsc) + } + }; + out.push(id.id()); + id + } + + fn size(&self) -> usize { + 1 + match self { + Node::Text(..) | Node::OneLine | Node::Rect => 0, + Node::Span(_, _, kids) | Node::Stack(kids) => kids.iter().map(Node::size).sum(), + Node::Pad(_, k) + | Node::Aligned(_, _, k) + | Node::Sized(_, _, k) + | Node::Scroll(_, k) => k.size(), + Node::Branch(p, a, b, _) => p.size() + a.size() + b.size(), + } + } + + /// Every one-step simplification: a wrapper replaced by what it wrapped, a + /// child dropped, a length or a word count reduced. Ordered cheapest-first + /// so the greedy walk takes the biggest bites early. + fn smaller(&self) -> Vec { + let mut out = Vec::new(); + let leaf = Node::Rect; + match self { + Node::Text(words, wrap) => { + if *words > 1 { + out.push(Node::Text(words / 2, *wrap)); + out.push(Node::Text(words - 1, *wrap)); + } + if *wrap { + out.push(Node::Text(*words, false)); + } + out.push(leaf); + } + Node::OneLine => out.push(Node::Rect), + Node::Rect => {} + Node::Span(down, gap, kids) => { + out.extend(kids.iter().cloned()); + for i in 0..kids.len() { + if kids.len() > 1 { + let mut less = kids.clone(); + less.remove(i); + out.push(Node::Span(*down, *gap, less)); + } + } + if *gap != 0.0 { + out.push(Node::Span(*down, 0.0, kids.clone())); + } + for (i, kid) in kids.iter().enumerate() { + for small in kid.smaller() { + let mut next = kids.clone(); + next[i] = small; + out.push(Node::Span(*down, *gap, next)); + } + } + } + Node::Stack(kids) => { + out.extend(kids.iter().cloned()); + for i in 0..kids.len() { + if kids.len() > 1 { + let mut less = kids.clone(); + less.remove(i); + out.push(Node::Stack(less)); + } + } + for (i, kid) in kids.iter().enumerate() { + for small in kid.smaller() { + let mut next = kids.clone(); + next[i] = small; + out.push(Node::Stack(next)); + } + } + } + Node::Pad(p, kid) => { + out.push((**kid).clone()); + if *p != 0.0 { + out.push(Node::Pad(0.0, kid.clone())); + } + out.extend( + kid.smaller() + .into_iter() + .map(|k| Node::Pad(*p, Box::new(k))), + ); + } + Node::Aligned(x, y, kid) => { + out.push((**kid).clone()); + for (nx, ny) in [(0, *y), (*x, 0)] { + if (nx, ny) != (*x, *y) { + out.push(Node::Aligned(nx, ny, kid.clone())); + } + } + out.extend( + kid.smaller() + .into_iter() + .map(|k| Node::Aligned(*x, *y, Box::new(k))), + ); + } + Node::Sized(x, y, kid) => { + out.push((**kid).clone()); + if x.is_some() { + out.push(Node::Sized(None, *y, kid.clone())); + } + if y.is_some() { + out.push(Node::Sized(*x, None, kid.clone())); + } + out.extend( + kid.smaller() + .into_iter() + .map(|k| Node::Sized(*x, *y, Box::new(k))), + ); + } + Node::Scroll(down, kid) => { + out.push((**kid).clone()); + out.extend( + kid.smaller() + .into_iter() + .map(|k| Node::Scroll(*down, Box::new(k))), + ); + } + Node::Branch(p, a, b, at) => { + out.push((**p).clone()); + out.push((**a).clone()); + out.push((**b).clone()); + for small in p.smaller() { + out.push(Node::Branch(Box::new(small), a.clone(), b.clone(), *at)); + } + for small in a.smaller() { + out.push(Node::Branch(p.clone(), Box::new(small), b.clone(), *at)); + } + for small in b.smaller() { + out.push(Node::Branch(p.clone(), a.clone(), Box::new(small), *at)); + } + } + } + out + } +} + +/// A declared size over about half the tree, the way `iris::random` puts them +/// in: on the way into every child rather than as a node kind of its own, so +/// readers of a size are dense rather than occasional. +fn sized(rng: &mut Rng, inner: Node) -> Node { + if !rng.chance() { + return inner; + } + let len = |rng: &mut Rng| match rng.below(4) { + 0 => Some(Len::px(20.0 + rng.below(180) as f32)), + 1 => Some(Len::REST), + _ => None, + }; + Node::Sized(len(rng), len(rng), Box::new(inner)) +} + +fn grow(rng: &mut Rng, depth: usize) -> Node { + if depth == 0 { + return match rng.below(4) { + 0 => Node::Text(1 + rng.below(WORDS.len()), true), + 1 => Node::OneLine, + _ => Node::Rect, + }; + } + let len = |rng: &mut Rng| match rng.below(4) { + 0 => Some(Len::px(20.0 + rng.below(180) as f32)), + 1 => Some(Len::REST), + 2 => Some(Len::rel(0.25 + rng.below(3) as f32 * 0.25)), + _ => None, + }; + let kid = |rng: &mut Rng| { + let inner = grow(rng, depth - 1); + sized(rng, inner) + }; + match rng.below(8) { + 0 => Node::Scroll(rng.chance(), Box::new(kid(rng))), + 1 => Node::Aligned(rng.below(4) as u8, rng.below(4) as u8, Box::new(kid(rng))), + 2 => Node::Pad(rng.below(24) as f32, Box::new(kid(rng))), + 3 => Node::Sized(len(rng), len(rng), Box::new(kid(rng))), + 4 => Node::Branch( + Box::new(kid(rng)), + Box::new(kid(rng)), + Box::new(kid(rng)), + rng.below(500) as f32, + ), + 5 => Node::Stack((0..2 + rng.below(2)).map(|_| kid(rng)).collect()), + _ => Node::Span( + rng.chance(), + rng.below(3) as f32 * 4.0, + (0..2 + rng.below(3)).map(|_| kid(rng)).collect(), + ), + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Case { + Resize, + Repaint, + ResizeRepaint, +} + +/// Runs one scenario warm and cold and says where they disagree. +fn diverges(node: &Node, case: Case) -> Option { + let start = if case == Case::Repaint { INNER } else { OUTER }; + let mut warm = Harness::new(start); + let mut warm_ids = Vec::new(); + let root = node.build(&mut warm, &mut warm_ids); + warm.state.root = Some(root); + // The frame that makes it warm: without it there is nothing retained and + // the comparison is two cold starts agreeing with each other. + warm.frame(); + if case != Case::Repaint { + warm.resize(INNER); + warm.frame(); + } + if case != Case::Resize { + for &id in &warm_ids { + warm.rsc.widgets_mut().get_dyn_mut(id); + } + warm.frame(); + } + + let mut cold = Harness::new(INNER); + let mut cold_ids = Vec::new(); + let root = node.build(&mut cold, &mut cold_ids); + cold.state.root = Some(root); + cold.frame(); + + for (i, (&w, &c)) in warm_ids.iter().zip(&cold_ids).enumerate() { + let (got, want) = (warm.region(&w), cold.region(&c)); + let same = match (got, want) { + (Some(g), Some(c)) => { + let d = |a: f32, b: f32| (a - b).abs() <= 0.05; + d(g.top_left.x, c.top_left.x) + && d(g.top_left.y, c.top_left.y) + && d(g.bot_right.x, c.bot_right.x) + && d(g.bot_right.y, c.bot_right.y) + } + (None, None) => true, + _ => false, + }; + if !same { + return Some(format!("widget {i}: warm {got:?} cold {want:?}")); + } + } + None +} + +/// Takes the first simplification that still fails, until none does. +fn shrink(mut node: Node, case: Case) -> Node { + loop { + let Some(next) = node + .smaller() + .into_iter() + .find(|small| diverges(small, case).is_some()) + else { + return node; + }; + node = next; + } +} + +fn env(name: &str, fallback: T) -> T { + std::env::var(name) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(fallback) +} + +#[test] +#[ignore = "a fuzzer; run it once the ordinary tests pass"] +fn no_grown_tree_lays_out_differently_warm_than_cold() { + let seeds: u64 = env("SHRINK_SEEDS", 400); + let depth: usize = env("SHRINK_DEPTH", 5); + let case = match env("SHRINK_CASE", String::from("resize")).as_str() { + "repaint" => Case::Repaint, + "resize-repaint" => Case::ResizeRepaint, + _ => Case::Resize, + }; + + for seed in 1..=seeds { + let node = grow(&mut Rng::new(seed), depth); + let Some(how) = diverges(&node, case) else { + continue; + }; + let small = shrink(node.clone(), case); + println!( + "seed {seed}: {how}\ngrown {} widgets, shrank to {}\n{small:#?}", + node.size(), + small.size() + ); + panic!("seed {seed} lays out differently warm than cold"); + } + let sizes: Vec = (1..=seeds) + .map(|seed| grow(&mut Rng::new(seed), depth).size()) + .collect(); + let total: usize = sizes.iter().sum(); + println!( + "{seeds} trees at depth {depth} agree: {} widgets total, largest {}", + total, + sizes.iter().max().copied().unwrap_or(0) + ); +} diff --git a/tests/unsettled.rs b/tests/unsettled.rs new file mode 100644 index 0000000..545e5d2 --- /dev/null +++ b/tests/unsettled.rs @@ -0,0 +1,99 @@ +//! The smallest tree that lays out differently on a second frame, shrunk from +//! a 402-widget one `tests/shrink.rs` grew. Both of these fail: a cold frame +//! leaves a wrapping text shaped at a width it was measured in rather than the +//! one it was given, and a repaint is what puts it right. So the warm-against- +//! cold oracle in `generated.rs` has been comparing against a tree that had +//! not settled, and some of what it called a warm defect is the cold side +//! being wrong. + +use iris::harness::Harness; +use iris::prelude::*; + +/// Six widgets, shrunk from a 402-widget tree the fuzzer found. Nothing about +/// the tree changes -- every widget is marked for redraw and the frame is +/// taken again -- so no box may move, and a warm frame has to land where a +/// cold one does. +fn plant(h: &mut Harness) -> Vec { + let plain = wtext("Wrapping").size(16).wrap(false).add(&mut h.rsc); + let wrapped = wtext("Wrapping shapes").size(16).wrap(true).add(&mut h.rsc); + let sized = SetSize { + inner: wrapped.add_strong(&mut h.rsc), + x: Some(Len::px(76.0)), + y: None, + } + .add(&mut h.rsc); + let aligned = Aligned { + inner: sized.add_strong(&mut h.rsc), + align: Align { + x: Some(AxisAlign::Pos), + y: Some(AxisAlign::Pos), + }, + } + .add(&mut h.rsc); + let stack = Stack { + children: vec![plain.add_strong(&mut h.rsc), aligned.add_strong(&mut h.rsc)], + size: StackSize::Child(0), + } + .add(&mut h.rsc); + let root = (stack,).span(Dir::RIGHT).add(&mut h.rsc); + h.set_root(root); + vec![ + plain.id(), + wrapped.id(), + sized.id(), + aligned.id(), + stack.id(), + root.id(), + ] +} + +/// The first frame does not reach the layout a second one does, so "cold" is +/// not a fixed point and comparing against it compares against a tree that +/// has not settled. +#[test] +#[ignore = "fails: the first frame shapes the text at a width it does not have"] +fn one_frame_is_enough() { + let mut h = Harness::new((640, 900)); + let ids = plant(&mut h); + let first = h.region(&ids[1]).unwrap(); + for _ in 0..3 { + for &id in &ids { + h.rsc.widgets_mut().get_dyn_mut(id); + } + h.frame(); + } + let settled = h.region(&ids[1]).unwrap(); + println!( + "first frame {} tall, settled {} tall", + first.bot_right.y - first.top_left.y, + settled.bot_right.y - settled.top_left.y + ); + assert_eq!( + first.bot_right.y - first.top_left.y, + settled.bot_right.y - settled.top_left.y, + "the first frame had not finished laying out" + ); +} + +#[test] +#[ignore = "fails for the same reason: the cold side has not settled either"] +fn repainting_everything_moves_nothing() { + let mut warm = Harness::new((640, 900)); + let ids = plant(&mut warm); + for &id in &ids { + warm.rsc.widgets_mut().get_dyn_mut(id); + } + warm.frame(); + + let mut cold = Harness::new((640, 900)); + let cold_ids = plant(&mut cold); + + let mut wrong = Vec::new(); + for (i, (&w, &c)) in ids.iter().zip(&cold_ids).enumerate() { + let (got, want) = (warm.region(&w), cold.region(&c)); + if got != want { + wrong.push(format!("widget {i}: warm {got:?} cold {want:?}")); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +}