Files
iris/tests/scenario/mod.rs
T
iris-aiandClaude Opus 5 76aaf06c0b Add SizeRule::{Min, Max, Clamp}, which the oracle refuses
`MaxSize` on the app's pin narrows the box it asks its child in and cuts the
answer to the cap; nothing on this branch does either, so the capability is
missing rather than merely unported. This is that capability as a rule beside
the widget, the way `Exact` already is: `Min(Len)`, `Max(Len)` and
`Clamp { min, max }`, resolved against the rel base a declared length is a
fraction of, and never carrying `leftover` -- a cap containing a share admits
several self-sizing fixed points (`docs/LAYOUT.md`, failed hypotheses).

Where it stands: every hand-written test passes, including the capability the
app actually used -- `a_capped_scroll_takes_its_viewport_from_the_cap` puts
400 px of content under a 100 px cap and gets a 100 px viewport with 300 to
scroll, which is what `MaxSize` gave. The 400-seed depth-5 scan does not
pass, and the reason is a design question rather than a slip, so this sits on
its own branch instead of in #19.

What the scan finds: a bound is the first rule whose effect depends on the
box its parent gives it, and the retained machinery hands a widget a box by
paths that never ask it again -- `place_in` from a re-placing parent, and
`reposition` after a parent's box moved. A decision made when the box was one
length therefore survives into a box of another, so warm and cold disagree
about a tree they agree on structurally. Four readings were measured over 400
seeds at depth 5:

- deciding at every ask and keeping it: seeds 291, 1, 120, 178, 64 differ.
- the same, re-decided at `place_in` too: seeds 1, 362, 188, 254, 156 differ,
  because that path's box is the one the answer chose rather than the one the
  widget was asked in.
- skipping a place its parent decided outright, which is the rule the share
  follows: worse -- the same widget then gets two decisions by two paths.
- the bound as an answer rule only, leaving the box alone: seeds 4 and 196,
  and those are the closest to passing by a wide margin.

The share is the one existing rule of this kind and it is stable because
`place_at` re-asks a child whose rel base it narrows, and because its
decision is baked into the retained place as a `Sized` length. Neither
protection generalises: a bound that binds is a length of the rel base, and
`Sized` cannot say "this slot, narrowed" for a `Within` place.

Also here, because a bound needed them: `Len::longer_than` and
`Bound::outside` share one comparison with the span; a rule that is a
fraction now pins its rel base whether the fraction is a length or a bound,
which was a real gap for `Exact` too; `widget_trait!` passes attributes
through, so the methods it defines can carry doc comments (none could);
`From<N> for Len`, so a bound reads `max_width(300)`; and `random.rs` grows
all three variants, with `describe` printing them so a failure can be written
out by hand.

Format, clippy with and without layout-diagnostics, and the suite (142 + 19 +
13 + 4) are clean. The fast ten-seed oracle passes; the long scans do not.
Neutering the bounds in the generator while leaving its draws in place puts
the same shapes back to green, so the divergence is the bounds and not the
new trees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 14:34:34 -04:00

515 lines
18 KiB
Rust

//! The scenarios both fuzzers run, over the tree a [`Plan`] describes.
//!
//! One implementation rather than two. 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. Both take a plan now, so whatever finds a counterexample can also
//! reduce it.
//!
//! Each target compiles this for itself, so what only one of them calls is
//! dead code in the other.
#![allow(dead_code)]
use iris::harness::Harness;
use iris::prelude::*;
use iris::random::{Edits, Kind, Plan, Rng, SpanEdit, Tree, build};
use std::collections::HashMap;
/// A seed per thread but one, since a seed grows, lays out and drops its tree
/// alone. A failing seed still shrinks and panics on its own thread.
pub fn over_seeds(seeds: Vec<u64>, run: impl Fn(u64) + Sync) {
let threads =
std::thread::available_parallelism().map_or(1, |n| n.get().saturating_sub(1).max(1));
let chunk = seeds.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
for part in seeds.chunks(chunk) {
let run = &run;
scope.spawn(move || part.iter().for_each(|&seed| run(seed)));
}
});
}
pub 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)
}
/// The window a tree is grown in, and the one a resize takes it to.
const OUTER: (f32, f32) = (1920.0, 1200.0);
const INNER: (f32, f32) = (640.0, 900.0);
const STILL: (f32, f32) = (900.0, 1200.0);
/// A way of changing what a span holds. Each is a shape worth its own case:
/// taking a child out of the middle is not the same as emptying a span, and
/// adding one is not the same as adding three.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Shuffle {
/// Every other child, so what is left is interleaved with what went.
EveryOther,
/// Everything but the first, which is the last step before empty.
AllButFirst,
/// Three more on the end at once.
AddThree,
/// The first out and three more on, so the count moves both ways.
SwapForThree,
/// One out of the middle and one on the end.
TradeOne,
}
impl Shuffle {
fn of(self, grown: usize) -> SpanEdit {
let all = |step: usize, from: usize| (from..grown).step_by(step).collect();
match self {
Self::EveryOther => SpanEdit {
detach: all(2, 0),
attach: 0,
},
Self::AllButFirst => SpanEdit {
detach: all(1, 1),
attach: 0,
},
Self::AddThree => SpanEdit {
detach: Vec::new(),
attach: 3,
},
Self::SwapForThree => SpanEdit {
detach: vec![0],
attach: 3,
},
Self::TradeOne => SpanEdit {
detach: vec![grown / 2],
attach: 1,
},
}
}
}
/// What a warm tree is put through before it is compared with a cold one
/// grown the way it was left.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Case {
/// Nothing changes, so no box may either. What this exercises is the
/// order a frame settles a dirty set in.
Repaint,
/// Every fifth widget rather than all of them: marking all of them
/// redraws the whole tree, which is a cold start reached the long way,
/// where the mixed case leaves a redrawn subtree beside a retained one.
RepaintSome,
Resize,
ResizeRepaint,
/// A resize and then a size change, so a retained answer is asked to
/// survive two different kinds of invalidation in a row.
ResizeSize,
/// A size change and then a resize, which is the other order and not the
/// same test: a length answered as a fraction of one box and kept as a
/// fraction of another agrees at the size it was changed at and parts
/// from it at every other one.
SizeResize,
/// A few declared sizes.
Size,
/// Every declared size at once, so every reader of a size has a changed
/// descendant in the same frame and the whole dirty set settles together.
EverySize,
Align,
/// Giving a widget a movable region of its own, or taking it away, is a
/// structural change: every primitive under it changes which chain
/// resolves it.
RegionNode,
/// The same children in a different order, which moves every one of them
/// without changing what any of them is.
Reorder,
Shuffle(Shuffle),
}
pub const ALL: [Case; 16] = [
Case::Repaint,
Case::RepaintSome,
Case::Resize,
Case::ResizeRepaint,
Case::ResizeSize,
Case::SizeResize,
Case::Size,
Case::EverySize,
Case::Align,
Case::RegionNode,
Case::Reorder,
Case::Shuffle(Shuffle::EveryOther),
Case::Shuffle(Shuffle::AllButFirst),
Case::Shuffle(Shuffle::AddThree),
Case::Shuffle(Shuffle::SwapForThree),
Case::Shuffle(Shuffle::TradeOne),
];
impl Case {
/// The name `CASE` selects it by, and the one a failure prints.
pub fn name(self) -> &'static str {
match self {
Self::Repaint => "repaint",
Self::RepaintSome => "repaint-some",
Self::Resize => "resize",
Self::ResizeRepaint => "resize-repaint",
Self::ResizeSize => "resize-size",
Self::SizeResize => "size-resize",
Self::Size => "size",
Self::EverySize => "every-size",
Self::Align => "align",
Self::RegionNode => "region-node",
Self::Reorder => "reorder",
Self::Shuffle(Shuffle::EveryOther) => "shuffle-every-other",
Self::Shuffle(Shuffle::AllButFirst) => "shuffle-all-but-first",
Self::Shuffle(Shuffle::AddThree) => "shuffle-add-three",
Self::Shuffle(Shuffle::SwapForThree) => "shuffle-swap-for-three",
Self::Shuffle(Shuffle::TradeOne) => "shuffle-trade-one",
}
}
pub fn named(name: &str) -> Option<Self> {
ALL.into_iter().find(|case| case.name() == name)
}
/// Grown in the first, compared in the second.
fn window(self) -> ((f32, f32), (f32, f32)) {
match self {
Self::Resize | Self::ResizeRepaint | Self::ResizeSize => (OUTER, INNER),
_ => (STILL, STILL),
}
}
/// The window the warm tree is taken to after the change, where the case
/// is about what the change left behind rather than about the change.
fn then_resize(self) -> Option<(f32, f32)> {
match self {
Self::SizeResize => Some(INNER),
_ => None,
}
}
}
fn mark(warm: &mut Harness, tree: &Tree, step: usize) {
for &id in tree.ids.iter().step_by(step) {
warm.rsc.widgets_mut().mark_for_redraw(id);
}
}
/// A length in pixels, or a cap over one: a rule that reads the box it is
/// given is the one a resize can change the effect of without changing the
/// rule, so a tree that never grows one leaves that unexercised.
fn a_rule(rng: &mut Rng) -> SizeRule {
let len = Len::px(20.0 + rng.below(180) as f32);
match rng.below(4) {
0 => SizeRule::Max(len),
1 => SizeRule::Min(len),
_ => LayoutLen::from(len).into(),
}
}
fn resize_one(warm: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> SizeRules {
let lens = SizeRules {
x: a_rule(rng),
y: a_rule(rng),
};
warm.rsc
.widgets_mut()
.set_size_rules(tree.sized[idx], lens.x, lens.y);
lens
}
fn realign_one(warm: &mut Harness, tree: &Tree, idx: usize, rng: &mut Rng) -> Align {
let side = |rng: &mut Rng| match rng.below(4) {
0 => None,
1 => Some(AxisAlign::NEG),
2 => Some(AxisAlign::CENTER),
_ => Some(AxisAlign::POS),
};
let align = Align {
x: side(rng),
y: side(rng),
};
let id = tree.aligned[idx];
let taken = RegionAlign::from(align);
for axis in Axis::BOTH {
warm.rsc.widgets_mut().set_alignment(id, axis, taken[axis]);
}
align
}
/// Every span's children in a different order, said both to the warm tree and
/// to the plan the cold one is grown from.
fn reorder(warm: &mut Harness, tree: &Tree, plan: &Plan) -> Plan {
for span in &tree.spans {
let children = &mut warm.rsc[span.id].children;
if !children.is_empty() {
children.rotate_left(1);
}
}
let mut out = plan.clone();
out.walk_mut(&mut |node| {
if let Kind::Span { order, .. } = &mut node.kind
&& !order.is_empty()
{
order.rotate_left(1);
}
});
out
}
/// Applies `shuffle` to every third span. What it takes out is given back to
/// the span's spares: the last share of a widget must outlive the comparison,
/// or its id is handed to something else and the two trees stop lining up.
fn reshuffle(warm: &mut Harness, tree: &mut Tree, shuffle: Shuffle) -> HashMap<usize, SpanEdit> {
let mut edits = HashMap::new();
for (idx, span) in tree.spans.iter_mut().enumerate().step_by(3) {
let edit = shuffle.of(span.grown);
let mut take = edit.detach.clone();
take.sort_unstable();
let children = &mut warm.rsc[span.id].children;
// Highest first, so an index means the same child however many of its
// neighbours are going too.
for j in take.into_iter().rev() {
if j < children.len() {
span.spares.push(children.remove(j));
}
}
let attach = edit.attach.min(span.spares.len());
let moved: Vec<_> = span.spares.drain(..attach).collect();
warm.rsc[span.id].children.extend(moved);
edits.insert(idx, edit);
}
edits
}
/// Changes the warm tree and answers with the plan a cold tree grown that way
/// comes from. Each arm settles its own frame, so a case that changes nothing
/// does not get a second one that could settle what the first left.
fn change(case: Case, warm: &mut Harness, tree: &mut Tree, plan: &Plan, rng: &mut Rng) -> Plan {
let some_sizes = |warm: &mut Harness, tree: &Tree, rng: &mut Rng| {
let mut sizes = HashMap::new();
for _ in 0..4 {
if tree.sized.is_empty() {
break;
}
let idx = rng.below(tree.sized.len());
sizes.insert(idx, resize_one(warm, tree, idx, rng));
}
sizes
};
let edits = match case {
Case::Resize => return plan.clone(),
Case::Repaint | Case::ResizeRepaint => {
mark(warm, tree, 1);
warm.frame();
return plan.clone();
}
Case::RepaintSome => {
mark(warm, tree, 5);
warm.frame();
return plan.clone();
}
Case::Reorder => {
let out = reorder(warm, tree, plan);
warm.frame();
return out;
}
Case::Size | Case::ResizeSize | Case::SizeResize => Edits {
sizes: some_sizes(warm, tree, rng),
..Default::default()
},
Case::EverySize => Edits {
sizes: (0..tree.sized.len())
.map(|idx| (idx, resize_one(warm, tree, idx, rng)))
.collect(),
..Default::default()
},
Case::Align => Edits {
aligns: (0..tree.aligned.len())
.step_by(3)
.map(|idx| (idx, realign_one(warm, tree, idx, rng)))
.collect(),
..Default::default()
},
Case::RegionNode => {
let mut nodes = HashMap::new();
for idx in (0..tree.nodes.len()).step_by(2) {
let id = tree.nodes[idx];
let take = !warm.rsc.widgets().is_region_node(id);
warm.rsc.widgets_mut().set_region_node(id, take);
nodes.insert(idx, take);
}
Edits {
nodes,
..Default::default()
}
}
Case::Shuffle(shuffle) => Edits {
spans: reshuffle(warm, tree, shuffle),
..Default::default()
},
};
warm.frame();
plan.edited(&edits)
}
/// What a widget was configured with, so a tree a fuzzer found can be written
/// out by hand. A failure is a lead; the fast test that replaces it has to be
/// buildable from what the failure printed.
fn describe(id: WidgetId, h: &Harness) -> String {
let rules = h.rsc.widgets().size_rules(id);
// A bound prints as itself: a failure is reproduced from what it printed,
// and a rule shown as "no rule" cannot be written out again.
let rule = |r: SizeRule| match r {
SizeRule::Free => "-".into(),
SizeRule::Exact(len) => format!("{len}"),
SizeRule::Min(min) => format!(">{}", LayoutLen::from(min)),
SizeRule::Max(max) => format!("<{}", LayoutLen::from(max)),
SizeRule::Clamp { min, max } => {
format!(">{}<{}", LayoutLen::from(min), LayoutLen::from(max))
}
};
let align = h.rsc.widgets().alignment(id);
let side = |a: AxisAlign| {
if a == AxisAlign::NEG {
"neg".into()
} else if a == AxisAlign::CENTER {
"mid".into()
} else if a == AxisAlign::POS {
"pos".into()
} else {
format!("{:.2}", a.rel())
}
};
// A rule and an alignment are properties of whatever carries them, so
// they print with that widget rather than as widgets of their own.
let mut out = describe_widget(id, h);
if (rules.x, rules.y) != (SizeRule::Free, SizeRule::Free) {
out += &format!("[x:{},y:{}]", rule(rules.x), rule(rules.y));
}
if align != RegionAlign::default() {
out += &format!("@{},{}", side(align.x), side(align.y));
}
out
}
fn describe_widget(id: WidgetId, h: &Harness) -> String {
let label = h.rsc.widgets().label(id).to_string();
let Some(widget) = h.rsc.widgets().get_dyn(id) else {
return label;
};
let any: &dyn std::any::Any = widget;
if let Some(w) = any.downcast_ref::<Span>() {
let sign = if w.dir.sign == Sign::Neg { "-" } else { "+" };
return format!(
"Span{{dir:{:?}{sign},gap:{},n:{}}}",
w.dir.axis,
w.gap,
w.children.len()
);
}
if let Some(w) = any.downcast_ref::<Pad>() {
let p = &w.padding;
return format!(
"Pad{{l:{},r:{},t:{},b:{}}}",
p.left, p.right, p.top, p.bottom
);
}
if let Some(w) = any.downcast_ref::<Stack>() {
return format!("Stack{{n:{}}}", w.children.len());
}
label
}
/// One widget's layout as it stands: the frame its fractions resolved
/// against, the box it was asked in, the box its drawing went in, and what
/// it reported. In window units, which is what both trees are in.
fn record(id: WidgetId, h: &Harness) -> String {
let active = &h.render.active[&id];
format!(
"rel_base {} region {} placement {} size {}",
active.rel_base, active.region, active.placement, active.size,
)
}
/// Runs `case` on the tree `plan` describes, warm and cold, and says where
/// the two disagree. `seed` chooses only the values a case picks at random,
/// so one plan under one case is one comparison however it was reached.
pub fn diverges(plan: &Plan, case: Case, seed: u64) -> Option<String> {
let (start, end) = case.window();
let mut warm = Harness::new(start);
let (root, mut tree) = build(&mut warm.rsc, plan);
warm.state.root = Some(root);
// The frame that makes it warm: without it nothing is retained and the
// comparison is two cold starts agreeing with each other.
warm.frame();
if start != end {
warm.resize(end);
warm.frame();
}
let cold_plan = change(case, &mut warm, &mut tree, plan, &mut Rng::new(seed));
// Whatever the change left, seen at another window: an answer kept as a
// fraction of the wrong length is the same number of pixels where it was
// made and a different one everywhere else.
let end = match case.then_resize() {
Some(after) => {
warm.resize(after);
warm.frame();
after
}
None => end,
};
let mut cold = Harness::new(end);
let (root, cold_tree) = build(&mut cold.rsc, &cold_plan);
cold.state.root = Some(root);
cold.frame();
let mut drawn = 0;
for (i, (&w, &c)) in tree.ids.iter().zip(&cold_tree.ids).enumerate() {
let (got, want) = (warm.region(&w), cold.region(&c));
drawn += got.is_some() as usize;
if got == want {
continue;
}
let places: HashMap<WidgetId, usize> = tree
.ids
.iter()
.enumerate()
.map(|(i, &id)| (id, i))
.collect();
// Where two trees disagree is rarely where the cause is, so the
// ancestry comes with it, marking the widgets that own a region.
let mut chain = Vec::new();
let mut records = Vec::new();
let mut at = Some(w);
while let Some(id) = at {
let active = &warm.render.active[&id];
let node = match active.move_idx == active.parent_move {
true => "",
false => "*",
};
chain.push(format!("{}{node}", describe(id, &warm)));
// What each level was asked in on both sides, since the level
// where the two stop agreeing is the one to look at rather than
// the leaf that reported the difference.
let cold_id = places.get(&id).and_then(|&i| cold_tree.ids.get(i));
records.push(format!(
" {}\n warm {}\n cold {}",
describe(id, &warm),
record(id, &warm),
cold_id.map_or("-".into(), |&id| record(id, &cold)),
));
at = active.parent;
}
return Some(format!(
"widget {i}\n warm {got:?}\n cold {want:?}\n {}\n{}",
chain.join(" < "),
records.join("\n"),
));
}
match drawn {
0 => Some("nothing was drawn".into()),
_ => None,
}
}