The oracle grew its trees from a seed and the shrinker grew its own, with every scenario written out on each side. So a failure the oracle found could not be handed to the shrinker: there was no tree to pass it, only a seed, and a seed cannot be made smaller. The shrinker could only grow its own trees and hope to meet the same shape, which it does not -- 20,000 of its trees never reproduced what the oracle's seed 18 shows at depth 6. `iris::random` now answers with a `Plan`: `plan(seed, depth, &edits)` draws one out of the random stream and `build(rsc, &plan)` makes the widgets, where `grow` did both at once. Every draw happens in the order it always has, so a seed still means the tree it meant -- checked by running the oracle at 1000 seeds of depth 6 before and after and getting the same three failures with the same boxes. `Plan::smaller` reduces one, `Plan::edited` applies an `Edits` to a tree that already exists, and `tests/scenario/` holds the fifteen cases both rigs now run over the same trees. A span keeps the order it holds its children in apart from the children themselves, so detaching, attaching and reordering leave the widgets made in the same order and two builds still line up index for index. `Tree::detached` is gone: `Spanned::spares` is everything made for a span that it does not hold, which is what both of those were. `tests/cases/plan.rs` pins the three properties the rest rests on: editing a plan is growing one with those edits, every simplification is smaller than what it came from, and reducing ends. The second caught this change's own defect, where dropping a side of a `Branch` duplicated another and grew the tree by four widgets. What it found, on its first run: `SHRINK_SEED=18 SHRINK_DEPTH=6 SHRINK_CASE=repaint-some` reduces 277 widgets to 5. A scroll inside a scroll, the inner one owning a movable region, and only the text at the bottom marked for redraw -- and the span lands 24px out, which is exactly the sized child's height. `git bisect` names `95fb4f9`, where `Masked` began reporting its box rather than its inner's size, so what the outer scroll is told its content measures now depends on whether the inner subtree was redrawn this frame. `tests/cases/unsettled.rs` has it written out, ignored until it is fixed. Checked: fmt, clippy over all targets with -D warnings, the workspace tests (79 + 11 + 15, one ignored for the defect above), and the 100-seed oracle over all fifteen cases at depth 4. The shrinker at 400 seeds of depth 5 now fails, which it did not before running the oracle's trees and cases: seeds 2 and 288 on region-node and 174 and 175 on repaint-some are unreduced leads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
457 lines
16 KiB
Rust
457 lines
16 KiB
Rust
//! The smallest trees that laid out differently warm than cold, each shrunk
|
|
//! by `tests/shrink.rs` from hundreds of widgets. The first two are a cold
|
|
//! frame that had not settled: a wrapping text shaped at a width it was
|
|
//! measured in rather than the one it was given. The rest are a widget
|
|
//! measured again in a box its own answer had decided, where the old answer
|
|
//! is a fixed point whatever the content now says. The last is neither: one
|
|
//! box length, composed two ways, landing either side of the boundary that
|
|
//! decided whether a child was drawn at all.
|
|
|
|
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 = wrapped.width(76).add(&mut h.rsc);
|
|
let aligned = sized;
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_alignment(sized, Axis::X, AxisAlign::POS);
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_alignment(sized, Axis::Y, AxisAlign::POS);
|
|
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]
|
|
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]
|
|
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"));
|
|
}
|
|
|
|
/// Six widgets, shrunk from 905. Everything inside the declared 189x176 box
|
|
/// is the same size whatever the output is, so a resize may not change any of
|
|
/// it -- but the text comes out 3.92px narrower warm than cold.
|
|
fn plant_fixed(h: &mut Harness) -> Vec<WidgetId> {
|
|
let words = "Wrapping shapes one source into as many lines as the box leaves";
|
|
let text = wtext(words).size(16).wrap(true).add(&mut h.rsc);
|
|
let aligned = text;
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_alignment(text, Axis::X, AxisAlign::NEG);
|
|
let inner = (aligned,).span(Dir::RIGHT).add(&mut h.rsc);
|
|
let sized = inner.sized((189, 176)).add(&mut h.rsc);
|
|
let filler = rect(Color::RED).add(&mut h.rsc);
|
|
let root = (filler, sized).span(Dir::RIGHT).add(&mut h.rsc);
|
|
h.state.root = Some(root.add_strong(&mut h.rsc));
|
|
vec![
|
|
text.id(),
|
|
aligned.id(),
|
|
inner.id(),
|
|
sized.id(),
|
|
filler.id(),
|
|
root.id(),
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn a_resize_does_not_reach_inside_a_box_of_declared_pixels() {
|
|
let mut warm = Harness::new((1920, 1200));
|
|
let ids = plant_fixed(&mut warm);
|
|
warm.frame();
|
|
warm.resize((640, 900));
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((640, 900));
|
|
let cold_ids = plant_fixed(&mut cold);
|
|
cold.frame();
|
|
|
|
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"));
|
|
}
|
|
|
|
/// Four widgets, shrunk from 486. A span's two children are swapped: warm by
|
|
/// moving them, cold by growing them that way. Same widgets, same sizes, one
|
|
/// ends up 29.9px from where the other does.
|
|
fn plant_pair(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, WeakWidget<Span>) {
|
|
let wrapped = wtext("Wrapping shapes one source into as many lines")
|
|
.size(16)
|
|
.wrap(true)
|
|
.add(&mut h.rsc);
|
|
let plain = wtext("one line, overflowing whatever it is given")
|
|
.size(16)
|
|
.wrap(false)
|
|
.add(&mut h.rsc);
|
|
let first: StrongWidget = wrapped.add_strong(&mut h.rsc);
|
|
let second: StrongWidget = plain.add_strong(&mut h.rsc);
|
|
let children = match swapped {
|
|
true => vec![second, first],
|
|
false => vec![first, second],
|
|
};
|
|
let span = Span {
|
|
children,
|
|
dir: Dir::RIGHT,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
let span_handle = span;
|
|
let aligned = span;
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_alignment(span, Axis::X, AxisAlign::CENTER);
|
|
h.state.root = Some(aligned.add_strong(&mut h.rsc));
|
|
(
|
|
vec![wrapped.id(), plain.id(), span.id(), aligned.id()],
|
|
span_handle,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn swapping_two_children_lands_where_growing_them_that_way_does() {
|
|
let mut warm = Harness::new((640, 900));
|
|
let (ids, span) = plant_pair(&mut warm, false);
|
|
warm.frame();
|
|
warm.rsc[span].children.rotate_left(1);
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((640, 900));
|
|
let (cold_ids, _) = plant_pair(&mut cold, true);
|
|
cold.frame();
|
|
|
|
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"));
|
|
}
|
|
|
|
/// Eight widgets, shrunk from 80. The scroll decides how wide to make its
|
|
/// content from what the content says, and hands that box down through a
|
|
/// pass-through; the span under it was given that box once, so nothing at its
|
|
/// own edge says the box was its own answer.
|
|
fn plant_scrolled(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget<Span>; 2]) {
|
|
let words = "Wrapping shapes one source into as many lines as the box leaves room for,";
|
|
let text = wtext(words).size(16).wrap(true).add(&mut h.rsc);
|
|
let filler = rect(Color::RED).add(&mut h.rsc);
|
|
let mut inner_children: Vec<StrongWidget> =
|
|
vec![text.add_strong(&mut h.rsc), filler.add_strong(&mut h.rsc)];
|
|
if swapped {
|
|
inner_children.rotate_left(1);
|
|
}
|
|
let inner = Span {
|
|
children: inner_children,
|
|
dir: Dir::RIGHT,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
let block = rect(Color::RED).add(&mut h.rsc);
|
|
let fixed = block.width(87).add(&mut h.rsc);
|
|
let mut outer_children: Vec<StrongWidget> =
|
|
vec![fixed.add_strong(&mut h.rsc), inner.add_strong(&mut h.rsc)];
|
|
if swapped {
|
|
outer_children.rotate_left(1);
|
|
}
|
|
let outer = Span {
|
|
children: outer_children,
|
|
dir: Dir::RIGHT,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
// Carried no rule even before rules were a property: it is here to be a
|
|
// widget between the span and the scroll, not to declare anything.
|
|
let through = (outer,).span(Dir::RIGHT).add(&mut h.rsc);
|
|
let scroll = Scroll::new(through.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
|
|
h.state.root = Some(scroll.add_strong(&mut h.rsc));
|
|
(
|
|
vec![
|
|
text.id(),
|
|
filler.id(),
|
|
inner.id(),
|
|
block.id(),
|
|
fixed.id(),
|
|
outer.id(),
|
|
through.id(),
|
|
scroll.id(),
|
|
],
|
|
[inner, outer],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_span_given_the_box_its_answer_decided_matches_a_cold_layout() {
|
|
let mut warm = Harness::new((640, 900));
|
|
let (ids, spans) = plant_scrolled(&mut warm, false);
|
|
warm.frame();
|
|
for span in spans {
|
|
warm.rsc[span].children.rotate_left(1);
|
|
}
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((640, 900));
|
|
let (cold_ids, _) = plant_scrolled(&mut cold, true);
|
|
cold.frame();
|
|
|
|
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"));
|
|
}
|
|
|
|
/// Reports a width derived from the box it is asked in. Reading through the
|
|
/// painter is its declaration that the answer holds for that width only.
|
|
struct Wider {
|
|
extra: f32,
|
|
}
|
|
|
|
impl Widget for Wider {
|
|
fn draw(&mut self, painter: &mut Painter) -> Size {
|
|
Size {
|
|
x: LayoutLen {
|
|
px: painter.px_len(Axis::X) + Px::from_f32(self.extra),
|
|
..LayoutLen::ZERO
|
|
},
|
|
y: LayoutLen::LEFTOVER,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn plant_wider(h: &mut Harness, extra: f32) -> (WeakWidget<Wider>, WidgetId) {
|
|
let content = Wider { extra }.add(&mut h.rsc);
|
|
let scroll = Scroll::new(content.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
|
|
let root = scroll;
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_alignment(scroll, Axis::X, AxisAlign::NEG);
|
|
h.set_root(root);
|
|
(content, scroll.id())
|
|
}
|
|
|
|
#[test]
|
|
fn a_scrolls_retained_answer_is_the_one_a_cold_layout_asks_for() {
|
|
let mut warm = Harness::new((100, 100));
|
|
let (content, scroll) = plant_wider(&mut warm, 50.0);
|
|
warm.rsc[content].extra = 70.0;
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((100, 100));
|
|
let (_, cold_scroll) = plant_wider(&mut cold, 70.0);
|
|
|
|
assert_eq!(warm.region(&scroll), cold.region(&cold_scroll));
|
|
}
|
|
|
|
/// Six widgets, shrunk from 266. `measured`'s box is exactly the height of its
|
|
/// one fixed child, which is the box a parent sizing itself from that answer
|
|
/// hands back -- so whether its leftover-only child was drawn at all came down
|
|
/// to the 0.00003 px the composed length differs by, one way warm and the
|
|
/// other cold.
|
|
fn plant_boundary(h: &mut Harness, swapped: bool) -> (Vec<WidgetId>, [WeakWidget<Span>; 2]) {
|
|
let filler = rect(Color::RED).add(&mut h.rsc);
|
|
let plain = wtext("one line, overflowing whatever it is given")
|
|
.size(16)
|
|
.wrap(false)
|
|
.add(&mut h.rsc);
|
|
let mut pair: Vec<StrongWidget> =
|
|
vec![filler.add_strong(&mut h.rsc), plain.add_strong(&mut h.rsc)];
|
|
if swapped {
|
|
pair.rotate_left(1);
|
|
}
|
|
let measured = Span {
|
|
children: pair,
|
|
dir: Dir::DOWN,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
// Takes the whole box on its own, so the span above has nothing left to
|
|
// divide and `measured` is given exactly the text's height.
|
|
let whole = rect(Color::RED).add(&mut h.rsc);
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_size_rules(whole, None, Some(LayoutLen::rel(1.0)));
|
|
let mut inner_children: Vec<StrongWidget> = vec![
|
|
measured.add_strong(&mut h.rsc),
|
|
whole.add_strong(&mut h.rsc),
|
|
];
|
|
if swapped {
|
|
inner_children.rotate_left(1);
|
|
}
|
|
let inner = Span {
|
|
children: inner_children,
|
|
dir: Dir::DOWN,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
h.rsc
|
|
.widgets_mut()
|
|
.set_size_rules(inner, None, Some(LayoutLen::px(198.0)));
|
|
// One more span above it: without a box composed through it, both trees
|
|
// round the same way and the boundary is never crossed.
|
|
let outer = (inner,).span(Dir::DOWN).add(&mut h.rsc);
|
|
h.set_root(outer);
|
|
(
|
|
vec![
|
|
filler.id(),
|
|
plain.id(),
|
|
measured.id(),
|
|
whole.id(),
|
|
inner.id(),
|
|
outer.id(),
|
|
],
|
|
[measured, inner],
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_box_that_only_rounds_past_its_fixed_children_leaves_nothing_over() {
|
|
let mut warm = Harness::new((640, 900));
|
|
let (ids, spans) = plant_boundary(&mut warm, false);
|
|
warm.frame();
|
|
for span in spans {
|
|
warm.rsc[span].children.rotate_left(1);
|
|
}
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((640, 900));
|
|
let (cold_ids, _) = plant_boundary(&mut cold, true);
|
|
cold.frame();
|
|
|
|
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"));
|
|
}
|
|
|
|
/// Five widgets, shrunk by `tests/shrink.rs` from the 277 the oracle's seed
|
|
/// 18 grows at depth 6. A scroll inside a scroll, the inner one owning a
|
|
/// movable region of its own, and only the text at the bottom marked for
|
|
/// redraw. Nothing about the tree changes, so no box may -- and the span
|
|
/// lands 76px further down the outer scroll warm than it does cold.
|
|
fn plant_nested_scrolls(h: &mut Harness) -> Vec<WidgetId> {
|
|
let text = wtext("one line, overflowing whatever it is given")
|
|
.size(16)
|
|
.wrap(false)
|
|
.add(&mut h.rsc);
|
|
let inner = Scroll::new(text.add_strong(&mut h.rsc), Axis::X).add(&mut h.rsc);
|
|
h.rsc.widgets_mut().set_region_node(inner.id(), true);
|
|
let filler = rect(Color::RED).add(&mut h.rsc);
|
|
h.rsc.widgets_mut().set_size_rules(
|
|
filler.id(),
|
|
Some(LayoutLen::px(87.0)),
|
|
Some(LayoutLen::px(24.0)),
|
|
);
|
|
let span = Span {
|
|
children: vec![inner.add_strong(&mut h.rsc), filler.add_strong(&mut h.rsc)],
|
|
dir: Dir::DOWN,
|
|
gap: Px::ZERO,
|
|
}
|
|
.add(&mut h.rsc);
|
|
let root = Scroll::new(span.add_strong(&mut h.rsc), Axis::Y).add(&mut h.rsc);
|
|
h.set_root(root);
|
|
vec![text.id(), inner.id(), filler.id(), span.id(), root.id()]
|
|
}
|
|
|
|
/// **A known defect, not a passing test.** Bisected to `95fb4f9`, which made
|
|
/// `Masked` report its box rather than its inner's size: `Scroll` clips
|
|
/// through one, so what the outer scroll is told its content measures now
|
|
/// depends on whether the inner subtree was redrawn this frame. Warm the
|
|
/// span sits at the top of the outer scroll and cold it sits 24px higher,
|
|
/// which is exactly the sized child's height. Un-ignore it with the fix.
|
|
#[test]
|
|
#[ignore = "known defect: a partial repaint moves a scrolled span, from 95fb4f9"]
|
|
fn redrawing_one_widget_does_not_move_what_scrolls_around_it() {
|
|
let mut warm = Harness::new((900, 1200));
|
|
let ids = plant_nested_scrolls(&mut warm);
|
|
warm.rsc.widgets_mut().get_dyn_mut(ids[0]);
|
|
warm.frame();
|
|
|
|
let mut cold = Harness::new((900, 1200));
|
|
let cold_ids = plant_nested_scrolls(&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"));
|
|
}
|