530 lines
18 KiB
Rust
530 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,
|
|
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)).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::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,
|
|
}
|
|
|
|
/// 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)
|
|
);
|
|
}
|