Files
iris/tests/cases/unsettled.rs
T
iris-aiandClaude Opus 5 aea878d141 Place a locally redrawn widget in its box, not just at its length
A dirty widget is asked again in the box its parent asked it in, and then
again in the box its parent chose from that answer. The second ask was
skipped whenever the two boxes were the same *length*, which is not the same
question: an offer as long as the final box but somewhere else is a different
box. `d3b0ebf` already compared whole boxes for `parent_must_place` and left
this one a length comparison, so the two halves of one decision disagreed.

It shows on a region node, which draws the box it drew in into its own move
entry. A scroll inside a scrolled span is offered the outer scroll's whole
viewport and placed 24px above it, the height of the sized child the outer
scroll snaps to the end of; redrawing only its text left it at the offer and
24px too low. `tests/cases/unsettled.rs` had that five-widget tree ignored as
a known defect and now runs it.

`px_region` names the walk both comparisons and `window_region` were writing
out.

Checked: fmt, clippy, 80 tests, the release oracle at 100 seeds, all fifteen
shrinker cases at 400 seeds of depth 5, and `tabs`, `view`, `minimal`,
`text`, `random` plus the tab replay byte-identical at 1920x1200 against
`98d4e98`. The `many` fixture's twenty-five counters are unchanged.

Fixed with it, from the handoff's unreduced leads: shrinker seeds 174 and 175
on `repaint-some` and seed 2 on `region-node`, and oracle seeds 18 and 190 at
depth 6. Still failing: shrinker seed 288 on `region-node`, and oracle seed
326 at depth 6, which reduces to 43 widgets around two `Branch`es and is not
this.

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

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 two are neither:
//! one box length, composed two ways, landing either side of the boundary
//! that decided whether a child was drawn at all, and one box as long as the
//! box a widget was offered but somewhere else.
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 its text marked for redraw. Nothing
/// about the tree changes, so no box may.
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 local redraw asks a dirty widget in the box its parent asked it in, and
/// then again in the box its parent chose from that answer. Skipping the
/// second ask because the two boxes are the same *length* left this inner
/// scroll, which owns a region node, drawn at its offer. The offer is the
/// outer scroll's whole viewport and the final box is 24px above it -- the
/// height of the sized child the outer scroll snaps to the end of -- so the
/// inner scroll and its text stayed 24px too low.
#[test]
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"));
}