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