Files
iris/tests/shrink.rs
T
iris-aiandClaude Opus 5 65f68bbb8a Reorder a span's children in the fuzzer, and find two fixed points
The shrinking fuzzer had no case for what `generated.rs` calls a reshuffle,
which was the only thing still failing there. `Case::Reorder` rotates every
span's children after a warm frame and compares against a tree grown that
way -- which needs a span's creation order kept apart from the order its
children are attached in, or the two trees make the same widgets in
different orders and cannot be lined up.

It found a four-widget tree, from 486, and the trace says the layout has
more than one answer rather than one answer reached twice.

    Aligned(mid, -, Span[ Text(wrap), OneLine ])

A span measures its children in its own box. Its own box is what its parent
gave it, from the size it reported, from those children. So with the
wrapping text second it is offered `cursor..end` of a span 663.376 wide and
asked for 357.44, which is what it already holds -- the size is valid, the
span reports 663.376 again, and nothing moves. Grown in that order from
scratch the span is offered the window, the text is asked for 334.06 and
answers 318.45, and the span settles at 624.38. Both are stable. Which one
you get depends on what the tree was before.

So this is not a stale drawing kept too long, and no rule about when to
keep one will fix it: it is a circular dependency with two solutions.
`Painter::settle` in `Aligned` -- place into the child's own size without
measuring there -- makes all four cases in `unsettled.rs` pass and breaks
two in `generated.rs`, whether or not the child is drawn first. Not kept;
the shape of the fix is the constraint a container measures under being
something it is given rather than something it ends up with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 01:38:26 -04:00

526 lines
18 KiB
Rust

//! 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<Node>, Vec<usize>),
Stack(Vec<Node>),
Pad(f32, Box<Node>),
Aligned(u8, u8, Box<Node>),
Sized(Option<Len>, Option<Len>, Box<Node>),
Scroll(bool, Box<Node>),
Branch(Box<Node>, Box<Node>, Box<Node>, f32),
}
fn axis_align(v: u8) -> Option<AxisAlign> {
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<WidgetId>,
spans: &mut Vec<WeakWidget<Span>>,
) -> 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))).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,
}
.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)).collect();
Stack {
children,
size: StackSize::Child(0),
}
.add_strong(&mut h.rsc)
}
Node::Pad(p, kid) => {
let inner = kid.build(h, out, spans);
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);
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, spans);
SetSize {
inner,
x: *x,
y: *y,
}
.add_strong(&mut h.rsc)
}
Node::Scroll(down, kid) => {
let inner = kid.build(h, out, spans);
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);
let wide = a.build(h, out, spans);
let narrow = b.build(h, out, spans);
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<Node> {
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::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()),
_ => {
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,
}
/// 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::<Vec<_>>();
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<String> {
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 root = node.build(&mut warm, &mut warm_ids, &mut warm_spans);
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();
}
// What the warm tree was moved into, grown that way from the start.
let want = match case {
Case::Reorder => reordered(node),
_ => node.clone(),
};
let mut cold = Harness::new(INNER);
let mut cold_ids = Vec::new();
let mut cold_spans = Vec::new();
let root = want.build(&mut cold, &mut cold_ids, &mut cold_spans);
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<T: std::str::FromStr>(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,
_ => 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<usize> = (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)
);
}