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 <noreply@anthropic.com>
This commit is contained in:
1 parent
386a0d1b8f
commit
b7caab3b9e
2 files changed
+564
No files matched your search
+465
@@ -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<Node>),
|
||||
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>) -> 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<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) => {
|
||||
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<String> {
|
||||
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<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,
|
||||
_ => 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)
|
||||
);
|
||||
}
|
||||
@@ -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<WidgetId> {
|
||||
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"));
|
||||
}
|
||||
Reference in new issue
Block a user