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>
100 lines
3.4 KiB
Rust
100 lines
3.4 KiB
Rust
//! A fuzzer that reduces its own counterexample.
|
|
//!
|
|
//! 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 the trees `iris::random` describes, takes them
|
|
//! apart, and prints the smallest one that still fails 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 or `all` for every one. `SHRINK_SEED`
|
|
//! takes a single seed, which is how a failure `generated` printed is handed
|
|
//! straight here: the two run the same cases over the same trees, so a seed
|
|
//! that fails there fails here and is reduced.
|
|
//!
|
|
//! 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.
|
|
|
|
#[path = "scenario/mod.rs"]
|
|
mod scenario;
|
|
|
|
use iris::random::{Edits, Plan, plan};
|
|
use scenario::{ALL, Case, diverges, env, over_seeds};
|
|
|
|
/// Takes the first simplification that still fails, until none does. The
|
|
/// simplifications come biggest first, so this walks down rather than
|
|
/// nibbling: a six-hundred-widget tree reaches single figures in a few
|
|
/// hundred builds.
|
|
fn shrink(mut node: Plan, case: Case, seed: u64) -> Plan {
|
|
loop {
|
|
let Some(next) = node
|
|
.smaller()
|
|
.into_iter()
|
|
.find(|small| diverges(small, case, seed).is_some())
|
|
else {
|
|
return node;
|
|
};
|
|
node = next;
|
|
}
|
|
}
|
|
|
|
fn cases() -> Vec<Case> {
|
|
match env("SHRINK_CASE", String::from("all")).as_str() {
|
|
"all" => ALL.to_vec(),
|
|
name => match Case::named(name) {
|
|
Some(case) => vec![case],
|
|
None => panic!(
|
|
"unknown SHRINK_CASE {name:?}; one of all, {}",
|
|
ALL.map(Case::name).join(", ")
|
|
),
|
|
},
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "a fuzzer; run it once the ordinary tests pass"]
|
|
fn no_grown_tree_lays_out_differently_warm_than_cold() {
|
|
let depth: usize = env("SHRINK_DEPTH", 5);
|
|
let cases = cases();
|
|
let seeds: Vec<u64> = match std::env::var("SHRINK_SEED")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
{
|
|
Some(seed) => vec![seed],
|
|
None => (1..=env("SHRINK_SEEDS", 400_u64)).collect(),
|
|
};
|
|
let count = seeds.len();
|
|
|
|
over_seeds(seeds, |seed| {
|
|
let grown = plan(seed, depth, &Edits::default());
|
|
for &case in &cases {
|
|
let Some(how) = diverges(&grown, case, seed) else {
|
|
continue;
|
|
};
|
|
let small = shrink(grown.clone(), case, seed);
|
|
println!(
|
|
"seed {seed} case {}: {how}\ngrown {} widgets, shrank to {}\n{small:#?}",
|
|
case.name(),
|
|
grown.size(),
|
|
small.size()
|
|
);
|
|
panic!(
|
|
"seed {seed} lays out differently warm than cold after {}",
|
|
case.name()
|
|
);
|
|
}
|
|
});
|
|
|
|
let sizes: Vec<usize> = (1..=count as u64)
|
|
.map(|seed| plan(seed, depth, &Edits::default()).size())
|
|
.collect();
|
|
println!(
|
|
"{count} trees at depth {depth} agree over {} case(s): {} widgets total, largest {}",
|
|
cases.len(),
|
|
sizes.iter().sum::<usize>(),
|
|
sizes.iter().max().copied().unwrap_or(0)
|
|
);
|
|
}
|