//! 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, /// Direction, gap, children in creation order, and the order they are /// attached in -- separate so a tree that reorders its children /// still makes the same widgets in the same order, and two /// builds line up index for index. Span(bool, f32, Vec, 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, spans: &mut Vec>, sized: &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, order) => { let mut built: Vec<_> = kids .iter() .map(|k| Some(k.build(h, out, spans, sized))) .collect(); // `order` is a permutation, so each is taken exactly once. let children = order .iter() .map(|&i| built[i].take().expect("order repeats an index")) .collect(); let handle = Span { children, dir: dir(*down), gap: *gap, ortho: match down { true => OrthoSize::Children, false => OrthoSize::Full, }, } .add(&mut h.rsc); spans.push(handle); handle.add_strong(&mut h.rsc) } Node::Stack(kids) => { let children = kids.iter().map(|k| k.build(h, out, spans, sized)).collect(); Stack { children, size: StackSize::Child(0), } .add_strong(&mut h.rsc) } Node::Pad(p, kid) => { let inner = kid.build(h, out, spans, sized); 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, spans, sized); for (axis, align) in [(Axis::X, axis_align(*x)), (Axis::Y, axis_align(*y))] { if let Some(align) = align { h.rsc.widgets_mut().set_alignment(&inner, axis, align); } } inner } Node::Sized(x, y, kid) => { let inner = kid.build(h, out, spans, sized); h.rsc.widgets_mut().set_size_rules(&inner, *x, *y); sized.push(inner.id()); inner } Node::Scroll(down, kid) => { let inner = kid.build(h, out, spans, sized); 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, spans, sized); let wide = a.build(h, out, spans, sized); let narrow = b.build(h, out, spans, sized); Branch { probe, wide, narrow, threshold: *at, } .add_strong(&mut h.rsc) } }; out.push(id.id()); id } /// The lengths every `Sized` node would carry after `resized`, in the /// order `build` pushes them. fn sized_lens(&self, out: &mut Vec<(Option, Option)>) { match self { Node::Text(..) | Node::OneLine | Node::Rect => {} Node::Span(_, _, kids, _) | Node::Stack(kids) => { kids.iter().for_each(|k| k.sized_lens(out)); } Node::Pad(_, k) | Node::Aligned(_, _, k) | Node::Scroll(_, k) => k.sized_lens(out), Node::Sized(x, y, k) => { k.sized_lens(out); out.push((resized_len(*x), resized_len(*y))); } Node::Branch(p, a, b, _) => { p.sized_lens(out); a.sized_lens(out); b.sized_lens(out); } } } 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, order) => { out.extend(order.iter().map(|&i| kids[i].clone())); for i in 0..kids.len() { if kids.len() > 1 { let mut less = kids.clone(); less.remove(i); let order = (0..less.len()).collect(); out.push(Node::Span(*down, *gap, less, order)); } } if *gap != 0.0 { out.push(Node::Span(*down, 0.0, kids.clone(), order.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, order.clone())); } } } 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::LEFTOVER), _ => 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::LEFTOVER), 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()), _ => { let kids: Vec<_> = (0..2 + rng.below(3)).map(|_| kid(rng)).collect(); let order = (0..kids.len()).collect(); Node::Span(rng.chance(), rng.below(3) as f32 * 4.0, kids, order) } } } #[derive(Clone, Copy, PartialEq)] enum Case { Resize, Repaint, ResizeRepaint, Reorder, SizeChange, } /// A different declared length, kept the same kind so the change is to the /// value alone. fn resized_len(len: Option) -> Option { len.map(|len| Len { px: len.px * 0.5 + 13.0, rel: len.rel * 0.5, leftover: len.leftover, }) } /// Every declared size changed, as a tree rather than as a change. fn resized(node: &Node) -> Node { match node { Node::Span(down, gap, kids, order) => Node::Span( *down, *gap, kids.iter().map(resized).collect(), order.clone(), ), Node::Stack(kids) => Node::Stack(kids.iter().map(resized).collect()), Node::Pad(p, k) => Node::Pad(*p, Box::new(resized(k))), Node::Aligned(x, y, k) => Node::Aligned(*x, *y, Box::new(resized(k))), Node::Sized(x, y, k) => Node::Sized(resized_len(*x), resized_len(*y), Box::new(resized(k))), Node::Scroll(d, k) => Node::Scroll(*d, Box::new(resized(k))), Node::Branch(p, a, b, at) => Node::Branch( Box::new(resized(p)), Box::new(resized(a)), Box::new(resized(b)), *at, ), leaf => leaf.clone(), } } /// Every span's children rotated by one, as a tree rather than as a change: /// what a warm frame reaches by moving them has to be where growing them that /// way lands. fn reordered(node: &Node) -> Node { match node { Node::Span(down, gap, kids, order) => { let kids = kids.iter().map(reordered).collect::>(); let mut order = order.clone(); order.rotate_left(1); Node::Span(*down, *gap, kids, order) } Node::Stack(kids) => Node::Stack(kids.iter().map(reordered).collect()), Node::Pad(p, k) => Node::Pad(*p, Box::new(reordered(k))), Node::Aligned(x, y, k) => Node::Aligned(*x, *y, Box::new(reordered(k))), Node::Sized(x, y, k) => Node::Sized(*x, *y, Box::new(reordered(k))), Node::Scroll(d, k) => Node::Scroll(*d, Box::new(reordered(k))), Node::Branch(p, a, b, at) => Node::Branch( Box::new(reordered(p)), Box::new(reordered(a)), Box::new(reordered(b)), *at, ), leaf => leaf.clone(), } } /// Runs one scenario warm and cold and says where they disagree. fn diverges(node: &Node, case: Case) -> Option { let resizes = matches!(case, Case::Resize | Case::ResizeRepaint); let repaints = matches!(case, Case::Repaint | Case::ResizeRepaint); let start = if resizes { OUTER } else { INNER }; let mut warm = Harness::new(start); let mut warm_ids = Vec::new(); let mut warm_spans = Vec::new(); let mut warm_sized = Vec::new(); let root = node.build(&mut warm, &mut warm_ids, &mut warm_spans, &mut warm_sized); 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 resizes { warm.resize(INNER); warm.frame(); } if repaints { for &id in &warm_ids { warm.rsc.widgets_mut().get_dyn_mut(id); } warm.frame(); } if case == Case::Reorder { for span in &warm_spans { warm.rsc[*span].children.rotate_left(1); } warm.frame(); } if case == Case::SizeChange { let mut lens = Vec::new(); node.sized_lens(&mut lens); for (id, (x, y)) in warm_sized.iter().zip(lens) { warm.rsc.widgets_mut().set_size_rules(*id, x, y); } warm.frame(); } // What the warm tree was moved into, grown that way from the start. let want = match case { Case::Reorder => reordered(node), Case::SizeChange => resized(node), _ => node.clone(), }; let mut cold = Harness::new(INNER); let mut cold_ids = Vec::new(); let mut cold_spans = Vec::new(); let mut cold_sized = Vec::new(); let root = want.build(&mut cold, &mut cold_ids, &mut cold_spans, &mut cold_sized); 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, "reorder" => Case::Reorder, "size-change" => Case::SizeChange, _ => 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) ); }