//! 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) ); }