fix(app-iced): 动效全局态按「测试隔离」修 —— 串行门 + 探针旁路(复审 P2-5 返工)

tip 8b6f090 上 7 条用例在 -j 2 下红,根因两层:
1) P2-5 引入的 configure_for_test 只挡调用点,而 App::update(Boot) 会走**产品** configure
   再按真探针定档 —— 本机(RDP 会话)SPI_GETCLIENTAREAANIMATION 报「动画关」⇒ 档位被压成
   NONE ⇒ 位移恒 0 ⇒ 门槛 B/C 的守卫全红(日志里 [motion] scale=0.00 就是它)。
2) 动效全局态(档位 + 进程级时间轴)在并行用例间共享,谁先写谁赢。

修法(判据一条没动):
- components :: motion :: serialize():**跨模块共用的唯一一把**串行门,subscription.rs 与
  update/motion_sync.rs 的用例都取它(idle_app 把 guard 一起返回,活到用例结束)。
- configure_for_test 置 thread-local BYPASS_PROBE,configure 读它 ⇒ 产品启动路径也不会再按
  探针改档;reset() 清旁路,供「故意走真探针」那条用例使用。
- NONE 仍要求严格 0、STANDARD 仍要求 12~20 帧与端点归位。
This commit is contained in:
AskaEth committed 2026-11-12 10:50:03 +08:00
1 parent 1edf645c2f
commit c21c6df51b
3 files changed
+55 -29

No files matched your search

+22 -22
View File
@@ -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"))
@@ -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,
"删除世界",
+26 -2
View File
@@ -84,13 +84,22 @@ thread_local! {
/// 上次收到帧的时刻(`advance` 用它把「帧到达」换算成流逝毫秒)。
#[allow(clippy::missing_const_for_thread_local)]
static LAST_FRAME: Cell<Option<Instant>> = const { Cell::new(None) };
/// **测试专用**:置真后 [`configure`] 不再读系统偏好(见 [`configure_for_test`])。
#[allow(clippy::missing_const_for_thread_local)]
static BYPASS_PROBE: Cell<bool> = const { Cell::new(false) };
}
/// 定档:`on_boot` 调一次(截图档给 `NONE`,CPU 档给 `HALF`,见 `app/update/boot_env.rs`)。
///
/// **系统「减少动效」在这里合成**(见模块文档的表):`Reduced` ⇒ `NONE`;`Unknown` ⇒ 不动。
pub fn configure(scale: MotionScale, pin: Option<f64>) {
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<f64>) {
/// 这条判据悄悄删掉。
#[cfg(test)]
pub fn configure_for_test(scale: MotionScale, pin: Option<f64>) {
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` 读真实时钟,单元测试不能等它)。