diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index a4002ac..e9e015e 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -110,7 +110,7 @@ mod tests { //! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是 //! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。 //! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。 - //! 时间轴用 `motion::advance_by` 摆;动效全局态由 `motion::serialize()` 的串行门独占。 + //! 时间轴用 `motion::advance_by` 摆(不等真实时钟);动效全局态是 thread-local,天然隔离。 use super::*; use crate::app::components::motion::FRAME_MS; @@ -147,9 +147,7 @@ mod tests { /// /// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完, /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 - fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) { - // **串行门**(复审 P2-5 第二半):动效全局态必须由本用例独占,别并行踩。 - let gate = motion::serialize(); + fn idle_app() -> App { motion::reset(); motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); @@ -159,7 +157,7 @@ mod tests { app.watch_until = None; run_until_idle(); assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); - (app, gate) + app } /// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。 @@ -178,7 +176,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, _gate) = idle_app(); + let mut app = idle_app(); assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧"); // 用户点了一次导航轨:**一条**消息,之后不再补任何消息 @@ -206,7 +204,7 @@ mod tests { /// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。 #[test] fn opening_a_detail_also_opens_the_frames_in_the_same_tick() { - let (mut app, _gate) = idle_app(); + let mut app = idle_app(); // 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化 let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances))); run_until_idle(); @@ -223,7 +221,7 @@ mod tests { /// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。 #[test] fn pages_that_render_elsewhere_do_not_start_a_track() { - let (mut app, _gate) = idle_app(); + let mut app = idle_app(); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts))); assert!( !motion::needs_frames(), @@ -240,7 +238,6 @@ 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(); @@ -260,7 +257,6 @@ mod tests { /// 变异证明:把 `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()); diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs index 008e8f7..37da015 100644 --- a/crates/app-iced/src/app/update/motion_sync.rs +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -2,23 +2,16 @@ //! //! ## 为什么这条不能在 `view` 里 //! -//! iced 0.14 的处理顺序是: -//! -//! ```text -//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338) -//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233) -//! ``` -//! -//! 也就是说**订阅表的求值早于视图**。第一版把起跑写在 `view` 里,订阅表当拍看不见轨道, -//! `frames()` 要等下一条消息才打开,而那一帧的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉, -//! 用户看到的是一次 12/8px 的偏移闪跳(截图通道因为 `has_screenshot()` 恒真,**结构上**不可能 -//! 暴露这条)。所以起跑搬到这里:`App::update` 处理完每条消息后调 [`App::sync_motion`]。 +//! iced 0.14 的顺序:`update()`(**订阅表在这里求值**,iced_winit `src/lib.rs:1338`)→ +//! `build_user_interfaces()`(这时才调 `view`,`src/lib.rs:1233`)⇒ **订阅表的求值早于视图**。 +//! 第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,`frames()` 要等下一条消息才打开,而那一帧 +//! 的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,用户看到的是一次 12/8px 的偏移闪跳(截图通道 +//! 因为 `has_screenshot()` 恒真,**结构上**不可能暴露这条)。所以起跑搬到这里。 //! //! ## 标识从哪来 //! -//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」(账户页 / -//! AI 独立页不归,它们之间切换不起跑),`nav_rank` 说「在**导航轨**上的左右次序」(方向只看它, -//! 不看屏键大小——见 [`nav_rank`])。 +//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」, +//! `nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,不看屏键大小——见 [`nav_rank`])。 use unlucky_presenter::router::Route; @@ -153,10 +146,9 @@ fn screen_key(route: &Route) -> Option { /// 这一屏在**导航轨**上的次序(`NavItem::ALL` 的下标)——左右方向的唯一真源。 /// /// 为什么不能用屏键(`Screen::ALL` 的下标):两处顺序不同。`Screen::ALL` 是 -/// `Instances / Download / Content / Settings / Logs`,而导航轨是 -/// `Instances / Download / Logs / Settings / Ai` ⇒ 用屏键会把 **Logs↔Settings 的左右镜像** -/// (复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等,方向退回按屏键比大小 -/// (`Download ↔ Content` 判成同级水平位移)。 +/// `… Content / Settings / Logs`,而导航轨是 `… Logs / Settings / Ai` ⇒ 用屏键会把 +/// **Logs↔Settings 的左右镜像**(复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等, +/// 方向退回按屏键比大小(`Download ↔ Content` 判成同级水平位移)。 /// /// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画, /// 返回 `None`——它们的切换本来就不起跑页面轨道。 @@ -207,13 +199,9 @@ mod tests { } } - /// 有窗口、首屏动效已跑完、旧页模型已清空的 App。 - /// - /// **定档走 `configure_for_test`(复审 P2-5)**:产品入口 `configure` 会读宿主机「减少动效」 - /// 开关,用例在关了系统动画的机器上就会红。真探针的接线由 `motion::tests::modal` 里那条用例守。 - fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) { - // **串行门**(复审 P2-5 第二半):动效全局态由本用例独占,别与并行用例互踩。 - let gate = motion::serialize(); + /// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见 + /// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。 + fn idle_app() -> App { motion::reset(); motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); @@ -221,10 +209,9 @@ mod tests { let _ = app.update(Message::Boot); app.watch_until = None; run_until_idle(); - // 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。 - let _ = app.update(Message::Frame); + let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉 assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); - (app, gate) + app } /// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。 @@ -278,7 +265,7 @@ mod tests { /// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。 #[test] fn the_previous_page_model_is_dropped_and_never_accumulates() { - let (mut app, _gate) = idle_app(); + let mut app = idle_app(); assert!(app.previous_view.is_none(), "前提:静息态没有旧页"); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); @@ -310,7 +297,7 @@ mod tests { /// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。 #[test] fn the_previous_page_is_a_frozen_snapshot() { - let (mut app, _gate) = idle_app(); + let mut app = idle_app(); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); let before = app .previous_view @@ -333,15 +320,15 @@ mod tests { /// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空; /// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes` - /// 不涨 = `on_dialog` 的闸门);④**Esc 那条独立路径**也挡住(`escape` 的闸门)。 - /// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。 + /// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。**抓不住**:几何级 + /// 命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。 #[test] fn the_exiting_dialog_is_already_closed_and_swallows_clicks() { use crate::app::dialog::Message as DialogMessage; use crate::app::update::dialog::{escape, on_dialog}; use unlucky_presenter::dialogs::{DialogKind, DialogSource}; - let (mut app, _gate) = idle_app(); + let mut app = idle_app(); let _ = app.state.dialogs.push( DialogSource::UserAction, "删除世界", @@ -381,4 +368,30 @@ mod tests { assert!(app.dialog_model.is_none(), "退场结束后不许留残影"); assert!(!motion::needs_frames(), "退场结束后帧泵必须停"); } + + /// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` —— + /// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→ + /// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。 + /// + /// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在 + /// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下 + /// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见 + /// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。 + /// + /// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一 + /// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。 + #[test] + fn a_pinned_scale_survives_boot() { + motion::reset(); + motion::configure_for_test(MotionScale::STANDARD, None); + let mut app = headless(); + app.window = Some(iced::window::Id::unique()); + let _ = app.update(Message::Boot); + assert_eq!( + motion::scale(), + MotionScale::STANDARD, + "on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)" + ); + motion::reset(); + } } diff --git a/crates/app-iced/src/components/motion/pump/mod.rs b/crates/app-iced/src/components/motion/pump/mod.rs index c0bedfc..9eb462e 100644 --- a/crates/app-iced/src/components/motion/pump/mod.rs +++ b/crates/app-iced/src/components/motion/pump/mod.rs @@ -291,7 +291,9 @@ impl Pump { Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => { self.modal = Modal::Open } - Modal::Closing { start_ms } if now - start_ms >= self.leg_ms(spec.exit.duration) => { + Modal::Closing { start_ms, .. } + if now - start_ms >= self.leg_ms(spec.exit.duration) => + { self.modal = Modal::Hidden; self.close_pending = true; } diff --git a/crates/app-iced/src/components/motion/tests/modal.rs b/crates/app-iced/src/components/motion/tests/modal.rs index b422688..c5cfb03 100644 --- a/crates/app-iced/src/components/motion/tests/modal.rs +++ b/crates/app-iced/src/components/motion/tests/modal.rs @@ -246,3 +246,70 @@ fn configure_goes_through_the_real_os_probe() { ); reset(); } + +/// **进场途中被关掉**:进度必须从**当前值**接着缩,不许先跳到 1.0 再退(复审 r2 的 P2-2)。 +/// +/// 抓得住:把 `begin_modal_close` 的起点改回恒 `1.0`(旧实现)⇒ 下面那条 +/// `(关掉后首帧)<=(关掉前一帧)` 立刻红——那就是"反向硬跳"。 +/// 抓不住:那一帧在真实屏幕上有多刺眼(D30 的观感,不是断言)。 +#[test] +fn closing_during_the_entrance_does_not_jump_to_fully_open() { + let mut pump = standard(); + assert!(pump.observe_modal(true), "起跑进场"); + for _ in 0..3 { + pump.advance(FRAME_MS); // 走到进场的 1/4 左右 + } + let before = pump.modal_frame().progress; + assert!( + before > 0.0 && before < 1.0, + "前提:进场走到半路,实测 {before}" + ); + + assert!(!pump.begin_modal_close(), "有动效 ⇒ 起跑退场"); + let first = pump.modal_frame().progress; + assert!( + first <= before + 1.0e-6, + "关掉后的首帧进度不许**变大**({before} → {first} 就是反向硬跳)" + ); + assert!( + (first - before).abs() < 0.05, + "起点必须接在当前进度上({before} → {first})" + ); + + // 之后单调缩到 0,且必须真的走完(交回一次「已关」) + let mut previous = first; + while pump.is_animating() { + pump.advance(FRAME_MS); + let now = pump.modal_frame().progress; + assert!( + now <= previous + 1.0e-6, + "退场必须单调缩小:{previous} → {now}" + ); + previous = now; + } + assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN); + assert!(pump.take_modal_closed()); +} + +/// **钉住的档压过任何来源的取值**(复审 r2 的根因机制)。 +/// +/// `App::on_boot` 会再调一次 `configure`(`app/update.rs:276`),值来自 +/// `boot_env::motion_scale(...)`(本机默认 HALF、`ULL_MOTION=off` 时 NONE)。本用例把那次调用 +/// 的**取值**直接喂进来:钉住 STANDARD 之后,`configure(HALF)` / `configure(NONE)` 都改不动它。 +/// +/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那一段 ⇒ 两行断言全红(= r2 之前的真红)。 +/// 抓不住:`boot_env` 自己那张分支表(env / 后端 tier 的取值规则,不在本 mission 的 scope)。 +#[test] +fn a_pinned_scale_survives_every_later_configure() { + reset(); + configure_for_test(MotionScale::STANDARD, None); + configure(MotionScale::HALF, None); // = 本机 ICED_BACKEND 未设时 boot_env 的默认档 + assert_eq!(scale(), MotionScale::STANDARD); + configure(MotionScale::NONE, None); // = ULL_MOTION=off 时 boot_env 的取值 + assert_eq!( + scale(), + MotionScale::STANDARD, + "钉住的档必须压过 on_boot 的取值" + ); + reset(); +}