From f48391b021e738504841165d9de3f9f65711c10d Mon Sep 17 00:00:00 2001 From: Lucas Meurer Date: Thu, 13 Aug 2026 16:39:12 +0200 Subject: [PATCH] Add `diagnostic_max_steps` to make `ExceededMaxStepsError` message more useful --- crates/egui_kittest/README.md | 4 +++ crates/egui_kittest/src/config.rs | 25 ++++++++++++--- crates/egui_kittest/src/lib.rs | 51 ++++++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/egui_kittest/README.md b/crates/egui_kittest/README.md index 8711aeabb..8522eb46b 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -52,6 +52,10 @@ threshold = 0.6 # (an absolute pixel count, not a fraction of the image) max_failed_pixels = 0 +# how many steps past `max_steps` `Harness::run` keeps stepping to report how many steps the ui +# would have needed to settle +diagnostic_max_steps = 100 + [windows] threshold = 0.6 max_failed_pixels = 0 diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index cdb74e00f..bad2a4216 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "snapshot")] - use std::io; use std::path::PathBuf; @@ -30,6 +28,14 @@ pub struct Config { #[serde(alias = "failed_pixel_count_threshold")] max_failed_pixels: usize, + /// How far past `max_steps` [`crate::Harness::try_run`] keeps stepping to find out how many + /// steps the ui would have needed. + /// + /// This tells a budget which is slightly too tight apart from a ui that never stops repainting. + /// + /// Default is 100. + diagnostic_max_steps: u64, + windows: OsConfig, mac: OsConfig, linux: OsConfig, @@ -41,6 +47,7 @@ impl Default for Config { output_path: PathBuf::from("tests/snapshots"), threshold: 0.6, max_failed_pixels: 0, + diagnostic_max_steps: 100, windows: Default::default(), mac: Default::default(), linux: Default::default(), @@ -144,16 +151,24 @@ impl Config { &INSTANCE } + /// How far past `max_steps` [`crate::Harness::try_run`] keeps stepping to find out how many + /// steps the ui would have needed. + /// + /// Default is 100. + pub fn diagnostic_max_steps(&self) -> u64 { + self.diagnostic_max_steps + } +} + +#[cfg(feature = "snapshot")] +impl Config { /// The output path for image snapshots. /// /// Default is "tests/snapshots". pub fn output_path(&self) -> PathBuf { self.output_path.clone() } -} -#[cfg(feature = "snapshot")] -impl Config { pub fn os_threshold(&self) -> crate::OsThreshold { let fallback = self.threshold; crate::OsThreshold { diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index fa8f26311..3942f4687 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -38,22 +38,43 @@ use egui::{ }; use kittest::Queryable; -use crate::app_kind::AppKind; +use crate::{app_kind::AppKind, config::config}; #[derive(Debug, Clone)] pub struct ExceededMaxStepsError { pub max_steps: u64, + + /// How many steps the ui would have needed to settle. + /// + /// `None` if it did not settle within `diagnostic_max_steps` (see `kittest.toml`) further + /// steps either, i.e. it just keeps repainting. + pub steps_to_settle: Option, + + /// How far past [`Self::max_steps`] we kept stepping to find [`Self::steps_to_settle`]. + pub diagnostic_max_steps: u64, + pub repaint_causes: Vec, } impl Display for ExceededMaxStepsError { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + write!(f, "Harness::run exceeded max_steps ({}). ", self.max_steps)?; + + match self.steps_to_settle { + Some(steps) => write!(f, "It would have settled after {steps} steps. ")?, + None => write!( + f, + "It did not settle within {} further steps either. ", + self.diagnostic_max_steps + )?, + } + write!( f, - "Harness::run exceeded max_steps ({}). If your expect your ui to keep repainting \ + "If your expect your ui to keep repainting \ (e.g. when showing a spinner) call Harness::step or Harness::run_steps instead.\ \nRepaint causes: {:#?}", - self.max_steps, self.repaint_causes, + self.repaint_causes, ) } } @@ -334,6 +355,12 @@ impl<'a, State> Harness<'a, State> { } fn _try_run(&mut self, sleep: bool) -> Result { + // Once the budget is blown we keep going for a while, purely to find out how many steps + // would have been needed. The repaint causes are the ones from the moment we blew it. + let diagnostic_max_steps = config().diagnostic_max_steps(); + let last_diagnostic_step = self.max_steps.saturating_add(diagnostic_max_steps); + let mut repaint_causes_at_max_steps = None; + let mut steps = 0; loop { steps += 1; @@ -343,14 +370,28 @@ impl<'a, State> Harness<'a, State> { // We only care about immediate repaints if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images { + if let Some(repaint_causes) = repaint_causes_at_max_steps { + return Err(ExceededMaxStepsError { + max_steps: self.max_steps, + steps_to_settle: Some(steps), + diagnostic_max_steps, + repaint_causes, + }); + } break; } else if sleep || wait_for_images { std::thread::sleep(Duration::from_secs_f32(self.step_dt)); } - if steps > self.max_steps { + if steps > self.max_steps && repaint_causes_at_max_steps.is_none() { + repaint_causes_at_max_steps = Some(self.ctx.repaint_causes()); + } + if steps > last_diagnostic_step { return Err(ExceededMaxStepsError { max_steps: self.max_steps, - repaint_causes: self.ctx.repaint_causes(), + steps_to_settle: None, + diagnostic_max_steps, + repaint_causes: repaint_causes_at_max_steps + .unwrap_or_else(|| self.ctx.repaint_causes()), }); } }