diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index a67c1a3..a4002ac 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -108,11 +108,9 @@ mod tests { //! ## 这一组按**运行时顺序**写(M192 r1 复审 P1-1 的教训) //! //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 - //! 「`update()`(订阅表在这里求值)→ `view()`」,于是这条守卫全绿、生产路径上却根本播不出来。 - //! 现在全部改走**真消息**:`app.update(Message::Page(Navigate(..)))` 之后再问订阅表, - //! 而且断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」这条假绿)。 - //! - //! 时间轴用 `motion::advance_by` 摆(不等真实时钟),每个用例在自己的线程上跑,互不干扰。 + //! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。 + //! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。 + //! 时间轴用 `motion::advance_by` 摆;动效全局态由 `motion::serialize()` 的串行门独占。 use super::*; use crate::app::components::motion::FRAME_MS; @@ -133,8 +131,8 @@ mod tests { /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 /// - /// 定档走 `motion::configure_for_test`(复审 P2-5):产品入口读宿主机「减少动效」开关, - /// 用例在关了系统动画的机器上会红;真探针的接线另有专门用例守。 + /// 定档走 `motion::configure_for_test`(P2-5):产品入口读宿主机「减少动效」开关,用例在关了 + /// 系统动画的机器上会红;真探针的接线另有专门用例守。 fn headless() -> App { App::with_presenter( Options::default(), @@ -149,7 +147,9 @@ mod tests { /// /// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完, /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 - fn idle_app() -> App { + fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) { + // **串行门**(复审 P2-5 第二半):动效全局态必须由本用例独占,别并行踩。 + let gate = motion::serialize(); motion::reset(); motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); @@ -159,7 +159,7 @@ mod tests { app.watch_until = None; run_until_idle(); assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); - app + (app, gate) } /// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 @@ -178,7 +178,7 @@ mod tests { /// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。 #[test] fn a_state_change_opens_the_frame_subscription_in_the_same_tick() { - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); // 用户点了一次导航轨:**一条**消息,之后不再补任何消息 @@ -206,7 +206,7 @@ mod tests { /// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。 #[test] fn opening_a_detail_also_opens_the_frames_in_the_same_tick() { - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); // 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化 let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); run_until_idle(); @@ -223,7 +223,7 @@ mod tests { /// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。 #[test] fn pages_that_render_elsewhere_do_not_start_a_track() { - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts))); assert!( !motion::needs_frames(), @@ -240,6 +240,7 @@ mod tests { /// 首屏那一条也因此要等窗口到了才算「第一眼」。 #[test] fn without_a_window_nothing_animates() { + let _gate = motion::serialize(); motion::reset(); motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); @@ -256,10 +257,10 @@ mod tests { } /// **`MotionScale::NONE` 真的零订阅**:不是「跑一遍 0 时长动画」。 - /// - /// 变异证明:把 `Pump::play` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。 + /// 变异证明:把 `Pump::play_page` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。 #[test] fn no_scale_requests_no_frames_at_all() { + let _gate = motion::serialize(); motion::reset(); let mut app = headless(); app.window = Some(iced::window::Id::unique()); @@ -281,10 +282,9 @@ mod tests { motion::reset(); } - /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,不会出现「动画与 Busy 各订一条」。 - /// - /// 口径是**全仓 `src/**`**(只扫本文件抓不到别人另开订阅),只看代码行。唯一允许的第二个文件 - /// 是 `pages/harness.rs`——那是 `pages_demo` 这条**独立 example** 自己的 app(不进产品路径)。 + /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔(不会「动画与 Busy 各订一条」)。 + /// 口径是**全仓 `src/**`**,只看代码行;唯一允许的第二个文件是 `pages/harness.rs` + /// (`pages_demo` 这条独立 example 自己的 app,不进产品路径)。 #[test] fn there_is_exactly_one_frame_subscription() { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); @@ -348,10 +348,10 @@ mod tests { /// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档由 /// `components/motion/transition.rs` 那张表决定(D31)。 /// - /// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴 - /// (变异:在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。 - /// **抓不住**:①表里某一档的数字写错(那是 `components/motion/tests` 的登记用例);②绕过 - /// 符号名的路径(转一手、通配导入)——只按符号名扫,不做类型分析。 + /// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴(变异: + /// 在 `pages/download.rs` 顶上加 `use unlucky_design::motion::DurationToken;` ⇒ 红)。 + /// **抓不住**:①某档数字写错(那是 `components/motion/tests` 的登记用例);②绕过符号名的 + /// 路径(转一手、通配导入)——只按符号名扫,不做类型分析。 #[test] fn pages_cannot_reach_for_a_duration() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs index 932e5c3..008e8f7 100644 --- a/crates/app-iced/src/app/update/motion_sync.rs +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -211,7 +211,9 @@ mod tests { /// /// **定档走 `configure_for_test`(复审 P2-5)**:产品入口 `configure` 会读宿主机「减少动效」 /// 开关,用例在关了系统动画的机器上就会红。真探针的接线由 `motion::tests::modal` 里那条用例守。 - fn idle_app() -> App { + fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) { + // **串行门**(复审 P2-5 第二半):动效全局态由本用例独占,别与并行用例互踩。 + let gate = motion::serialize(); motion::reset(); motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); @@ -222,7 +224,7 @@ mod tests { // 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。 let _ = app.update(Message::Frame); assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); - app + (app, gate) } /// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。 @@ -276,7 +278,7 @@ mod tests { /// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。 #[test] fn the_previous_page_model_is_dropped_and_never_accumulates() { - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); assert!(app.previous_view.is_none(), "前提:静息态没有旧页"); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); @@ -308,7 +310,7 @@ mod tests { /// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。 #[test] fn the_previous_page_is_a_frozen_snapshot() { - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); let before = app .previous_view @@ -339,7 +341,7 @@ mod tests { use crate::app::update::dialog::{escape, on_dialog}; use unlucky_presenter::dialogs::{DialogKind, DialogSource}; - let mut app = idle_app(); + let (mut app, _gate) = idle_app(); let _ = app.state.dialogs.push( DialogSource::UserAction, "删除世界", diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 3587581..27c540c 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -84,13 +84,22 @@ thread_local! { /// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。 #[allow(clippy::missing_const_for_thread_local)] static LAST_FRAME: Cell> = const { Cell::new(None) }; + /// **测试专用**:置真后 [`configure`] 不再读系统偏好(见 [`configure_for_test`])。 + #[allow(clippy::missing_const_for_thread_local)] + static BYPASS_PROBE: Cell = const { Cell::new(false) }; } /// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。 /// /// **系统「减少动效」在这里合成**(见模块文档的表):`Reduced` ⇒ `NONE`;`Unknown` ⇒ 不动。 pub fn configure(scale: MotionScale, pin: Option) { - let scale = effective_scale(scale, ReduceMotion::probe(), pin.is_some()); + // 用例走 `configure_for_test` 置位旁路:**产品启动路径**(`on_boot` → 这里)也会重新定档, + // 只挡调用点是不够的。 + let scale = if BYPASS_PROBE.with(Cell::get) { + scale + } else { + effective_scale(scale, ReduceMotion::probe(), pin.is_some()) + }; PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); } @@ -102,7 +111,8 @@ pub fn configure(scale: MotionScale, pin: Option) { /// 这条判据悄悄删掉。 #[cfg(test)] pub fn configure_for_test(scale: MotionScale, pin: Option) { - PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); + BYPASS_PROBE.with(|flag| flag.set(true)); + configure(scale, pin); } /// 档位与系统偏好的**合成本身**(纯函数,可断言)。 @@ -121,6 +131,18 @@ pub fn scale() -> MotionScale { PUMP.with(|pump| pump.borrow().scale()) } +/// **测试专用**:动效全局态的串行门(复审 P2-5 的第二半)。 +/// +/// `Pump` 与「旁路探针」都是 thread-local,正常并行用例本就互不干扰;但用例会读**产品启动路径** +/// 写下的档位,而 libtest 允许 `--test-threads=1`(届时所有用例同线程)。凡是会改写动效全局态的 +/// 用例都先取这把锁,把时序固定下来——**跨模块共用的只有这一把**(`subscription.rs` 与 +/// `motion_sync.rs` 都从这里取)。 +#[cfg(test)] +pub fn serialize() -> std::sync::MutexGuard<'static, ()> { + static GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + GATE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + /// 已推进的帧数。 pub fn samples() -> u64 { PUMP.with(|pump| pump.borrow().samples()) @@ -236,6 +258,8 @@ pub fn shifted<'a, M: 'a>( pub fn reset() { PUMP.with(|pump| *pump.borrow_mut() = pump::Pump::new()); LAST_FRAME.with(|last| last.set(None)); + // 旁路也要清:「故意走真探针」的那条用例靠 `reset()` 回到未旁路状态。 + BYPASS_PROBE.with(|flag| flag.set(false)); } /// 测试专用:按给定毫秒推进(`advance` 读真实时钟,单元测试不能等它)。