//! Schedule hygiene checks (issue #143). //! //! Bevy runs systems with conflicting data access in nondeterministic order //! unless an ordering edge exists. Nothing enforced this historically, and //! the headless gameplay cluster carries a large legacy backlog of such //! pairs — most benign (event/resource writers that never observably race), //! but unproven. Rather than a permanently red hard-error test or no test //! at all, this is a RATCHET: the count may only go down. New conflicting //! pairs fail CI immediately; the backlog can be triaged incrementally //! (add `.before`/`.after` where order matters, `.ambiguous_with` where it //! provably doesn't, then lower `AMBIGUITY_BASELINE`). //! //! To see the offending pair names, add the `bevy_debug` / debug feature to //! the bevy dev-dependency (system names are stripped otherwise) and set //! `ambiguity_detection: LogLevel::Error` manually. //! //! Scope note: `CoreGamePlugin` (the full composition) performs real //! storage/platform I/O in `build` and cannot run in a unit test; the //! cluster below is the same one the engine's headless behaviour tests use. #[cfg(test)] mod tests { use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings}; use bevy::prelude::*; use crate::auto_complete_plugin::AutoCompletePlugin; use crate::card_plugin::CardPlugin; use crate::game_plugin::GamePlugin; use crate::hud_plugin::HudPlugin; use crate::settings_plugin::SettingsPlugin; use crate::table_plugin::TablePlugin; use crate::ui_focus::UiFocusPlugin; use crate::ui_modal::UiModalPlugin; /// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the /// same day (#143, PRs #146–#149) — this is now a hard gate. If your /// change trips this assertion you have added a pair of systems with /// conflicting data access and no ordering edge: add `.before`/`.after` /// where order matters, or `.ambiguous_with` the relevant domain set /// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets) /// where it provably does not. Do not raise this constant. const AMBIGUITY_BASELINE: usize = 0; fn cluster_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(GamePlugin) .add_plugins(TablePlugin) .add_plugins(CardPlugin) .add_plugins(HudPlugin) .add_plugins(AutoCompletePlugin) .add_plugins(UiModalPlugin) .add_plugins(UiFocusPlugin) .add_plugins(SettingsPlugin::headless()); app.init_resource::>(); app } #[test] fn update_schedule_ambiguities_do_not_grow() { let mut app = cluster_app(); app.edit_schedule(Update, |s| { s.set_build_settings(ScheduleBuildSettings { ambiguity_detection: LogLevel::Error, ..Default::default() }); }); // With LogLevel::Error the first schedule build panics when any // ambiguity exists, and the panic message begins with the pair // count. Catch it and ratchet on that count. (Parsing a panic // message is brittle by design — if a Bevy upgrade rewords it, // this test fails loudly and the parse below needs one-line // maintenance, which is preferable to losing the ratchet.) let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { app.update(); })); let count = match result { Ok(()) => 0, Err(payload) => { let msg = payload .downcast_ref::() .cloned() .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string())) .unwrap_or_default(); let parsed = msg .split(" pairs of systems") .next() .and_then(|prefix| prefix.split_whitespace().last()) .and_then(|n| n.parse::().ok()); parsed.unwrap_or_else(|| { panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}") }) } }; assert_eq!( count, AMBIGUITY_BASELINE, "system-order ambiguities changed from the enforced baseline. \ Add .before/.after or .ambiguous_with at the new registration site \ (or, if the count legitimately dropped below a nonzero baseline, \ lower AMBIGUITY_BASELINE).", ); } }