From 1edf645c2fb07a77330d1674399b292c3b65f8ef Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 23:03:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20=E5=A4=8D=E5=AE=A1=20p2=20?= =?UTF-8?q?=E4=BA=94=E6=9D=A1=EF=BC=88=E6=96=B9=E5=90=91=E5=8F=96=E8=BD=A8?= =?UTF-8?q?=E5=BA=8F=20/=20=E9=80=80=E5=9C=BA=E9=87=8D=E5=BC=80=20/=20?= =?UTF-8?q?=E9=81=AE=E7=BD=A9=E5=8F=A3=E5=BE=84=20/=20=E6=81=92=E7=9C=9F?= =?UTF-8?q?=E6=96=AD=E8=A8=80=20/=20=E6=8E=A2=E9=92=88=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P2-1 方向改由**导航轨序**决定:新增 nav_rank(route)(NavItem::ALL 下标), Pump::observe 收 (screen, rank, detail),forward 比较 (rank, key) 而非只比屏键 —— 修掉 Logs↔Settings 的左右镜像;Download↔Content 同 rank,仍判同级水平(已登记偏离)。 - P2-2 退场途中开新框:observe_modal 先当场收尾(Hidden + 待关)再起 Opening; sync_motion 收尾时若队列里还有框就不把 dialog_model 置空。 - P2-3 只改文字:遮罩起手是**整块画布色的不透明矩形**(把页面内容遮住),不是透明看不见。 - P2-4 删掉恒真断言(单个 Option 的 <=1),写明「不累积」是字段形状 + mem::replace 给的; tests/timeline.rs 与 tests/modal.rs 的每条用例都补了「抓不住」。 - P2-5 新增 motion::configure_for_test(绕过 OS 探针),用例一律走它; 另留一条故意走真探针的用例证明 Reduced ⇒ NONE 的合成仍在产品入口上。 - 顺带:pump 的模态状态机拆到 pump/modal.rs(守 400 行硬线)。 --- crates/app-iced/src/app/dialog/view.rs | 11 +- crates/app-iced/src/app/subscription.rs | 21 +- crates/app-iced/src/app/update/motion_sync.rs | 226 ++++++++---------- crates/app-iced/src/components/motion/mod.rs | 19 +- .../motion/{pump.rs => pump/mod.rs} | 131 ++-------- .../src/components/motion/pump/modal.rs | 124 ++++++++++ .../src/components/motion/tests/mod.rs | 83 +++++-- .../src/components/motion/tests/modal.rs | 87 ++++++- .../src/components/motion/tests/timeline.rs | 18 +- 9 files changed, 446 insertions(+), 274 deletions(-) rename crates/app-iced/src/components/motion/{pump.rs => pump/mod.rs} (74%) create mode 100644 crates/app-iced/src/components/motion/pump/modal.rs diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index f4962ae..e4d381b 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -105,8 +105,12 @@ pub(super) fn backdrop_color(ui: &Ui) -> Color { /// /// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级** /// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经 -/// 合成好的不透明色**从画布色插值到 scrim 色:`0` 时与背后画布同色(看不见)、`1` 时是原遮罩, -/// 全程 `a = 1.0`,两个后端出图一致。`1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。 +/// 合成好的不透明色从画布色插值到 scrim 色,全程 `a = 1.0`,两个后端出图一致。 +/// `1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。 +/// +/// ⚠️ **口径(复审 P2-3 订正)**:`0` 时这一层是**整块画布色的不透明矩形**,它把页面内容 +/// **整块遮住**(不是"透明所以看不见",也不是"看得到页面")——D27 禁面板 α,所以只有这一种 +/// 可行实现。观感是「画面先被画布色盖住、随进度变暗到 scrim」,不是「页面透过淡遮罩隐隐可见」。 pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color { let t = progress.clamp(0.0, 1.0); if t >= 1.0 { @@ -353,7 +357,8 @@ mod motion_tests { 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b } - /// 遮罩:`0` 是画布色(看不见)、`1` 是原遮罩(逐位相同)、全程不透明且单调变暗。 + /// 遮罩:`0` 是整块画布色(**把页面内容盖住**,不是透明)、`1` 是原遮罩(逐位相同)、 + /// 全程不透明且单调变暗。 #[test] fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() { for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { diff --git a/crates/app-iced/src/app/subscription.rs b/crates/app-iced/src/app/subscription.rs index 9524e60..a67c1a3 100644 --- a/crates/app-iced/src/app/subscription.rs +++ b/crates/app-iced/src/app/subscription.rs @@ -132,6 +132,9 @@ mod tests { }; /// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。 + /// + /// 定档走 `motion::configure_for_test`(复审 P2-5):产品入口读宿主机「减少动效」开关, + /// 用例在关了系统动画的机器上会红;真探针的接线另有专门用例守。 fn headless() -> App { App::with_presenter( Options::default(), @@ -148,7 +151,7 @@ mod tests { /// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。 fn idle_app() -> App { motion::reset(); - motion::configure(MotionScale::STANDARD, None); + motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); // 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档) app.window = Some(iced::window::Id::unique()); @@ -238,7 +241,7 @@ mod tests { #[test] fn without_a_window_nothing_animates() { motion::reset(); - motion::configure(MotionScale::STANDARD, None); + motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); assert!(app.window.is_none(), "前提:还没拿到窗口句柄"); let _ = app.update(Message::Boot); @@ -262,7 +265,7 @@ mod tests { app.window = Some(iced::window::Id::unique()); let _ = app.update(Message::Boot); app.watch_until = None; - motion::configure(MotionScale::NONE, None); + motion::configure_for_test(MotionScale::NONE, None); // 三种状态变化:换页 / 换详情 / 再换回来 let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); @@ -278,11 +281,10 @@ mod tests { motion::reset(); } - /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。 + /// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,不会出现「动画与 Busy 各订一条」。 /// - /// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看 - /// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`—— - /// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。 + /// 口径是**全仓 `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"); @@ -342,9 +344,8 @@ mod tests { } /// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。 - /// - /// 口径:`pages/**` 的产品代码里不许出现时长的任何入口(`DurationToken` / `page_spec` / - /// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档、多久由 + /// 口径:`pages/**` 的产品代码不许出现时长的任何入口(`DurationToken` / `page_spec` / + /// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档由 /// `components/motion/transition.rs` 那张表决定(D31)。 /// /// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴 diff --git a/crates/app-iced/src/app/update/motion_sync.rs b/crates/app-iced/src/app/update/motion_sync.rs index 69b32e8..932e5c3 100644 --- a/crates/app-iced/src/app/update/motion_sync.rs +++ b/crates/app-iced/src/app/update/motion_sync.rs @@ -9,20 +9,16 @@ //! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233) //! ``` //! -//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render` -//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、 -//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉, -//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真, -//! **结构上**不可能暴露这条。 -//! -//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`], -//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。 +//! 也就是说**订阅表的求值早于视图**。第一版把起跑写在 `view` 里,订阅表当拍看不见轨道, +//! `frames()` 要等下一条消息才打开,而那一帧的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉, +//! 用户看到的是一次 12/8px 的偏移闪跳(截图通道因为 `has_screenshot()` 恒真,**结构上**不可能 +//! 暴露这条)。所以起跑搬到这里:`App::update` 处理完每条消息后调 [`App::sync_motion`]。 //! //! ## 标识从哪来 //! -//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走 -//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑, -//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。 +//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」(账户页 / +//! AI 独立页不归,它们之间切换不起跑),`nav_rank` 说「在**导航轨**上的左右次序」(方向只看它, +//! 不看屏键大小——见 [`nav_rank`])。 use unlucky_presenter::router::Route; @@ -30,6 +26,8 @@ use crate::app::components::motion; use crate::pages; use super::boot_env::MotionFixture; +use crate::app::message::nav_item_of; +use crate::shell::NavItem; use super::*; @@ -39,7 +37,9 @@ impl App { // M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。 // 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的 // 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。 - if motion::take_modal_closed() { + // M256 r2(P2-2):退场走完只在**队列里确实没有框**时才丢掉画面模型——退场途中开了新框时 + // `observe_modal(true)` 已把旧退场当场收尾(并留下一次「已关」),照样置空会闪一下。 + if motion::take_modal_closed() && self.state.dialogs.current().is_none() { self.dialog_model = None; self.view_dirty = true; } @@ -57,14 +57,13 @@ impl App { Route::Detail(id) => Some(id.as_str()), _ => None, }; - motion::observe(screen, detail); + motion::observe(screen, nav_rank(route), detail); // M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`: // 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。 motion::observe_modal(self.state.dialogs.current().is_some()); - // M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**。 - // 这条是硬要求:`previous_view` 是一份完整的页面模型,留着就是「每换一次页多一份」的 - // 线性增长——长时间常驻的启动器上这是明确的缺陷,不是"小浪费"。 + // M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面 + // 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。 if !motion::needs_frames() { self.previous_view = None; } @@ -151,6 +150,24 @@ fn screen_key(route: &Route) -> Option { .map(|index| index as u64 + 1) } +/// 这一屏在**导航轨**上的次序(`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` 判成同级水平位移)。 +/// +/// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画, +/// 返回 `None`——它们的切换本来就不起跑页面轨道。 +fn nav_rank(route: &Route) -> Option { + let item = nav_item_of(route); + NavItem::ALL + .iter() + .position(|candidate| *candidate == item) + .map(|index| index as u8) +} + #[cfg(test)] mod tests { use super::*; @@ -190,112 +207,105 @@ mod tests { } } - /// 一个有窗口、首屏动效已经跑完的 App(`sync_motion` 在没有窗口时直接返回)。 + /// 有窗口、首屏动效已跑完、旧页模型已清空的 App。 + /// + /// **定档走 `configure_for_test`(复审 P2-5)**:产品入口 `configure` 会读宿主机「减少动效」 + /// 开关,用例在关了系统动画的机器上就会红。真探针的接线由 `motion::tests::modal` 里那条用例守。 fn idle_app() -> App { motion::reset(); - motion::configure(MotionScale::STANDARD, None); + motion::configure_for_test(MotionScale::STANDARD, None); let mut app = headless(); app.window = Some(iced::window::Id::unique()); let _ = app.update(Message::Boot); app.watch_until = None; run_until_idle(); - // 再走一条消息:`sync_motion` 在这一拍把启动期那次换页的 `previous_view` 烧掉 - // (首屏从「构造时的模型」换到真路由,本身就是一次内容标识变化)。 + // 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。 let _ = app.update(Message::Frame); assert!(!motion::needs_frames(), "前提:首屏动效已经跑完"); app } - /// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。 + /// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。 + /// + /// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回 + /// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好 + /// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。 + /// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。 #[test] - fn only_the_five_screens_get_a_key() { - let keys: Vec> = [ - Route::Instances, - Route::Download, - Route::Content, - Route::Settings, - Route::Logs, - Route::Detail("survival".to_string()), - Route::Accounts, - Route::Agent, - ] - .iter() - .map(screen_key) - .collect(); - - assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]); + fn the_rail_order_and_the_screen_table_disagree_on_purpose() { + let keyed = |route: &Route| (screen_key(route), nav_rank(route)); assert_eq!( - keys[5], - Some(1), - "详情页归「实例」那一屏(与 Screen::of 同源)" + keyed(&Route::Detail("survival".to_string())), + (Some(1), Some(0)), + "详情归「实例」" ); - assert_eq!(keys[6], None, "账户页不归 pages 画"); - assert_eq!(keys[7], None, "AI 独立页不归 pages 画"); + assert_eq!( + keyed(&Route::Accounts), + (None, None), + "账户不归 pages 画、也不在轨上" + ); + assert_eq!( + keyed(&Route::Agent), + (None, Some(4)), + "AI 不归 pages 画,但在轨最下面" + ); + assert_eq!( + nav_rank(&Route::Content), + Some(1), + "内容库与 Download 共用导航项" + ); + + // 两条**相反**的不等关系:这就是 P2-1 修的镜像 + assert!( + screen_key(&Route::Settings) < screen_key(&Route::Logs), + "屏键里 Settings 在 Logs 之前" + ); + assert_eq!( + nav_rank(&Route::Logs), + Some(2), + "轨上 Logs 在 Settings 之前" + ); + assert_eq!(nav_rank(&Route::Settings), Some(3)); } - /// **旧页模型用完即焚**(M256 门槛 B):过渡结束的那一拍 `previous_view` 必须是 `None`。 + /// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。 /// - /// 抓得住:把 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` 删掉 - /// ——那样每换一次页就永久多留一份完整模型,长时间跑下去是线性增长。 - /// 抓不住:模型有多大(这条只看"有没有留")。 + /// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条 + /// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是 + /// `Option`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**); + /// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。 #[test] - fn the_previous_page_model_is_dropped_when_the_transition_ends() { + fn the_previous_page_model_is_dropped_and_never_accumulates() { let mut app = idle_app(); assert!(app.previous_view.is_none(), "前提:静息态没有旧页"); let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download))); - assert!( - app.previous_view.is_some(), - "换页那一拍必须留下旧页(退场要画它)" - ); assert_eq!( app.previous_view.as_ref().map(|model| model.screen), Some(pages::Screen::Instances), - "旧页必须是**切换前**那一屏" + "换页那一拍必须留下**切换前**那一屏" ); - run_until_idle(); - // 下一拍消息(真机上就是最后一帧)收尾。 let _ = app.update(Message::Frame); - assert!( - app.previous_view.is_none(), - "过渡结束必须当拍丢掉旧页模型(不许随换页次数线性增长)" - ); - } + assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型"); - /// **连续多次换页也只保有 1 份旧模型**(门槛 B 的另一半)。 - #[test] - fn repeated_page_changes_never_accumulate_models() { - let mut app = idle_app(); - let routes = [ - Route::Download, - Route::Content, - Route::Settings, - Route::Instances, - ]; - for route in routes { + // 快速连点:中间只走 4 帧就点下一次 + for route in [Route::Content, Route::Settings, Route::Instances] { let _ = app.update(Message::Page(pages::Message::Navigate(route))); - // 中间只走 4 帧再点下一次(模拟快速连点导航轨) for _ in 0..4 { motion::advance_by(FRAME_MS); } - let count = usize::from(app.previous_view.is_some()); - assert!(count <= 1, "同一时刻最多只有 1 份旧页模型,实测 {count}"); + assert!(app.previous_view.is_some(), "过渡中途必须留着旧页"); } run_until_idle(); - let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs))); - run_until_idle(); let _ = app.update(Message::Frame); - assert!( - app.previous_view.is_none(), - "连续 5 次换页之后仍应是 None(不是攒了 5 份)" - ); + assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None"); } /// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。 /// - /// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成"每次刷新都换"——那样旧页会跟着 - /// presenter 变,画面上就是两个页面同时动内容。 + /// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变, + /// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。 #[test] fn the_previous_page_is_a_frozen_snapshot() { let mut app = idle_app(); @@ -306,9 +316,8 @@ mod tests { .map(|model| (model.screen, model.window_width)); assert!(before.is_some()); - // 过渡期间来一次「同屏刷新」(主题/下载进度这一类的形状:不换屏) app.view_dirty = true; - app.refresh_view(); + app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状) motion::advance_by(FRAME_MS); assert_eq!( @@ -320,20 +329,14 @@ mod tests { ); } - /// **退场窗口内那个框已经关掉,且不可再交互**(tower 的硬要求)。 - /// - /// 四件事分别断言:①逻辑状态当拍就空;②画面还在退场(`dialog_model` 仍 `Some` ⇒ 不是硬跳); - /// ③这 150ms 内再点一次**什么都不发生**(`on_dialog` 的闸门);④**Esc 那条独立路径**也挡住。 - /// - /// **抓得住**:把 `on_dialog` 顶部的 `if motion::modal_closing()` 删掉(第二次点击会走进 - /// `close(app)`,`refreshes` 涨一次 ⇒ 红);或让逻辑关闭延后(①红);或让退场直接清模型(②红)。 - /// **抓不住**:几何级的命中测试——「点在那个坐标上会不会命中幽灵框」。本仓的单元测试没有 - /// iced 的布局树,`zoom.rs` 里的缩放也**不改**命中矩形(记在它自己的"已知局限"里)。 - /// 这一条由闸门保证:**无论命中在哪,退场期间的弹窗消息一律不生效**。 + /// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空; + /// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes` + /// 不涨 = `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::on_dialog; + use crate::app::update::dialog::{escape, on_dialog}; use unlucky_presenter::dialogs::{DialogKind, DialogSource}; let mut app = idle_app(); @@ -348,46 +351,29 @@ mod tests { ); app.view_dirty = true; app.refresh_view(); - // 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机的顺序一致) + // 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致) let _ = app.update(Message::Frame); assert!(motion::needs_frames(), "开框必须在同一拍起跑进场"); - run_until_idle(); // 进场跑完(`Open` 稳态) + run_until_idle(); assert!(app.dialog_model.is_some(), "前提:框已经画出来了"); - // 用户点「取消」:逻辑当拍关闭,画面开始退场 let _ = on_dialog(&mut app, DialogMessage::Secondary); - assert!( - app.state.dialogs.current().is_none(), - "退场窗口内逻辑上必须**已经**关掉(任何状态读取者都该看到没有框)" - ); - assert!(motion::modal_closing(), "画面还在退场"); - assert!( - app.dialog_model.is_some(), - "退场窗口内仍画得到旧框(否则就是一次硬跳)" - ); - assert!(motion::needs_frames(), "退场要帧"); + assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉"); + assert!(motion::modal_closing(), "②画面还在退场"); + assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)"); - // 150ms 内再点一次原来的主按钮位置:必须被闸门吞掉 let refreshes = app.refreshes; let _ = on_dialog(&mut app, DialogMessage::Primary); - assert_eq!( - app.refreshes, refreshes, - "退场窗口内的点击必须被吞掉(连一次重画都不该发生)" - ); + assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉"); assert!( app.state.dialogs.current().is_none(), - "幽灵点击不许复活那个框" + "③幽灵点击不许复活那个框" ); - assert!(app.dialog_model.is_some(), "也不许把它重新打开"); - // **键盘入口单列一条**:`Esc` 不走 `on_dialog`(它是 `Message::Agent(Key)` 那条路), - // 必须在 `escape` 里单独挡住,否则退场那 150ms 里 Esc 会落到 AI 栏去收起面板。 assert!( - crate::app::update::dialog::escape(&mut app), - "退场窗口内的 Esc 也必须被吃掉(不许落到 AI 栏)" + escape(&mut app), + "④退场期 Esc 也必须被吃掉(不许落到 AI 栏)" ); - assert!(app.state.dialogs.current().is_none()); - // 退场走完:下一拍的 `sync_motion` 把它丢掉,帧泵停 run_until_idle(); let _ = app.update(Message::Frame); assert!(app.dialog_model.is_none(), "退场结束后不许留残影"); diff --git a/crates/app-iced/src/components/motion/mod.rs b/crates/app-iced/src/components/motion/mod.rs index 3dbb7f7..3587581 100644 --- a/crates/app-iced/src/components/motion/mod.rs +++ b/crates/app-iced/src/components/motion/mod.rs @@ -94,6 +94,17 @@ pub fn configure(scale: MotionScale, pin: Option) { PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); } +/// **测试专用**定档:绕过系统偏好探针,只落档位(M256 r2 / 复审 P2-5)。 +/// +/// 为什么必须有它:`configure` 会读宿主机「减少动效」开关,而用例在关了系统动画的机器上会红; +/// 用例一律走这里才与宿主机无关。**至少保留一条**故意走真探针的用例 +/// (`tests::modal::configure_goes_through_the_real_os_probe`),否则等于把「OS 偏好真的参与合成」 +/// 这条判据悄悄删掉。 +#[cfg(test)] +pub fn configure_for_test(scale: MotionScale, pin: Option) { + PUMP.with(|pump| pump.borrow_mut().configure(scale, pin)); +} + /// 档位与系统偏好的**合成本身**(纯函数,可断言)。 /// /// `pinned` = 取证相位钉住(`ULL_MOTION_AT`):多帧序列图要在同一台机器上重复拍到同一相位, @@ -123,13 +134,15 @@ pub fn needs_frames() -> bool { /// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。 /// /// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页), -/// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 从这几个标识里判: +/// `rank` 是这一屏在**导航轨**(`shell::nav::NavItem::ALL`)里的次序——**左右方向只看它**, +/// 不看屏键大小(两处顺序不同:`Screen::ALL` 里 Settings 在 Logs 前,导航轨里反过来), +/// `detail` 是详情页的实例 id。方向与深度由 [`pump::Pump::observe`] 判: /// **同级换页走水平、进/出深层走纵向,旧页反向退场**。 /// /// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值, /// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。 -pub fn observe(screen: Option, detail: Option<&str>) { - let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail)); +pub fn observe(screen: Option, rank: Option, detail: Option<&str>) { + let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, rank, detail)); if started { LAST_FRAME.with(|last| last.set(Some(Instant::now()))); } diff --git a/crates/app-iced/src/components/motion/pump.rs b/crates/app-iced/src/components/motion/pump/mod.rs similarity index 74% rename from crates/app-iced/src/components/motion/pump.rs rename to crates/app-iced/src/components/motion/pump/mod.rs index d977bd6..c0bedfc 100644 --- a/crates/app-iced/src/components/motion/pump.rs +++ b/crates/app-iced/src/components/motion/pump/mod.rs @@ -15,9 +15,12 @@ use unlucky_design::motion::{DurationToken, MotionScale}; +mod modal; + use super::transition::{ - enter_offset, exit_offset, modal_spec, page_spec, Kind, ModalFrame, PageFrame, PageSpec, + enter_offset, exit_offset, modal_spec, page_spec, Kind, PageFrame, PageSpec, }; +pub(crate) use modal::Modal; /// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。 /// @@ -45,22 +48,6 @@ struct PageTrack { start_ms: f64, } -/// 模态的状态机。 -/// -/// 为什么要状态机而不是一条进度:**弹窗开着的时候不能订阅帧**。`Open` 是一个"没有轨道"的 -/// 稳态([`Pump::is_animating`] 为假),只有起手与收尾那两段是动画。 -#[derive(Debug, Clone, Copy, PartialEq)] -enum Modal { - /// 没有弹窗。 - Hidden, - /// 进场中。 - Opening { start_ms: f64 }, - /// 完全展开(稳态,不占帧)。 - Open, - /// 退场中。 - Closing { start_ms: f64 }, -} - /// 帧泵:一条时间轴 + 最多一条页面轨道 + 一个模态状态机。 /// /// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而 @@ -77,6 +64,8 @@ pub struct Pump { /// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。 close_pending: bool, screen: Option, + /// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。 + rank: Option, detail: Option, painted: bool, samples: u64, @@ -94,6 +83,7 @@ impl Pump { modal: Modal::Hidden, close_pending: false, screen: None, + rank: None, detail: None, painted: false, samples: 0, @@ -110,6 +100,7 @@ impl Pump { self.pin = pin; self.painted = false; self.screen = None; + self.rank = None; self.detail = None; self.pending_ms = 0.0; self.modal = Modal::Hidden; @@ -217,16 +208,25 @@ impl Pump { /// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。 /// /// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。 - pub fn observe(&mut self, screen: Option, detail: Option<&str>) -> bool { + pub fn observe(&mut self, screen: Option, rank: Option, detail: Option<&str>) -> bool { let kind = if !self.painted { self.painted = true; screen.map(|_| Kind::Boot) } else if self.screen != screen { - screen.map(|new| match self.screen { - Some(old) => Kind::Peer { forward: new > old }, + match (self.screen, screen, self.rank, rank) { + // 左右方向 = **用户在导航轨上看到的次序**(`rank`),不是内部屏键的大小: + // 两者顺序不同(`Screen::ALL` 里 Settings 在 Logs 前,导航轨里 Logs 在 Settings + // 前)⇒ 用屏键会把 Logs↔Settings 镜像。同 rank(Download / Content 共用导航项) + // 时退到屏键比大小,方向仍是"向后 / 向前"。 + (Some(old_key), Some(new_key), Some(old_rank), Some(new_rank)) => { + Some(Kind::Peer { + forward: (new_rank, new_key) > (old_rank, old_key), + }) + } // 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。 - None => Kind::Boot, - }) + (_, Some(_), _, _) => Some(Kind::Boot), + _ => None, + } } else if screen.is_some() && self.detail.as_deref() != detail { Some(match (self.detail.is_some(), detail.is_some()) { (false, true) => Kind::Push, @@ -239,6 +239,7 @@ impl Pump { }; self.screen = screen; + self.rank = rank; if self.detail.as_deref() != detail { self.detail = detail.map(str::to_owned); } @@ -247,92 +248,6 @@ impl Pump { // ------------------------------------------------------------ 模态 - /// 弹窗的**逻辑**开关(每帧 update 侧同步一次)。 - /// - /// - 开了 → 从 [`Modal::Hidden`] 起跑进场;已经在进/开着就不动。 - /// - 关了(不是走 [`Pump::begin_modal_close`] 的那条路,即框已经被别处直接关掉)→ - /// 立刻落 [`Modal::Hidden`](没有框可画,也就没有退场可言)。 - /// - /// 返回 `true` = 起跑了进场轨道。 - pub fn observe_modal(&mut self, open: bool) -> bool { - if open { - if matches!(self.modal, Modal::Hidden) { - if self.scale.is_off() { - self.modal = Modal::Open; - return false; - } - self.modal = Modal::Opening { - start_ms: self.start_now(), - }; - return true; - } - return false; - } - if !matches!(self.modal, Modal::Closing { .. }) { - self.modal = Modal::Hidden; - self.close_pending = false; - } - false - } - - /// 请求关掉弹窗:有动效就起跑退场(返回 `false`,真正的关闭由 [`Pump::take_modal_closed`] - /// 交回调用方),关着动效就返回 `true`(**立刻关,不留 0 时长动画**)。 - pub fn begin_modal_close(&mut self) -> bool { - if self.scale.is_off() || matches!(self.modal, Modal::Hidden) { - self.modal = Modal::Hidden; - self.close_pending = false; - return true; - } - self.modal = Modal::Closing { - start_ms: self.start_now(), - }; - false - } - - /// 退场是否已经走完(取走一次,之后返回 `false`)。 - pub fn take_modal_closed(&mut self) -> bool { - std::mem::take(&mut self.close_pending) - } - - /// 退场是否正在跑(这段时间里的弹窗消息要被吞掉:框已经在缩小,再点一次「删除」不该再生效)。 - pub fn modal_closing(&self) -> bool { - matches!(self.modal, Modal::Closing { .. }) - } - - /// 这一帧模态该画成什么样。 - pub fn modal_frame(&self) -> ModalFrame { - match self.modal { - Modal::Hidden => ModalFrame::HIDDEN, - Modal::Open => ModalFrame { - visible: true, - progress: 1.0, - }, - Modal::Opening { start_ms } => { - let spec = modal_spec(); - let t = leg_fraction( - self.elapsed_ms() - start_ms, - self.leg_ms(spec.enter.duration), - ); - ModalFrame { - visible: true, - progress: spec.enter.easing.sample(t), - } - } - Modal::Closing { start_ms } => { - let spec = modal_spec(); - let t = leg_fraction( - self.elapsed_ms() - start_ms, - self.leg_ms(spec.exit.duration), - ); - ModalFrame { - visible: true, - // 退场是**反向**走同一条线:1 → 0(与进场共用 `modal_scale` 的映射)。 - progress: 1.0 - spec.exit.easing.sample(t), - } - } - } - } - // ------------------------------------------------------------ 时间轴 /// 推进时间轴。返回 `true` = 这一帧真的推进了(进度变了 ⇒ 要重画)。 diff --git a/crates/app-iced/src/components/motion/pump/modal.rs b/crates/app-iced/src/components/motion/pump/modal.rs new file mode 100644 index 0000000..758e696 --- /dev/null +++ b/crates/app-iced/src/components/motion/pump/modal.rs @@ -0,0 +1,124 @@ +//! 模态状态机与采样(`pump` 的子模块,只为守 400 行硬线而拆)。 +//! +//! | 状态 | 进入条件 | 离开条件 | +//! |---|---|---| +//! | [`Modal::Hidden`] | `configure` / 退场走完 / 被别处硬关 | 开框 | +//! | [`Modal::Opening`] | `observe_modal(true)` | 进场腿走完 ⇒ `Open` | +//! | [`Modal::Open`] | 进场走完 | `begin_modal_close()` | +//! | [`Modal::Closing`] | `begin_modal_close()` | 退场腿走完 ⇒ `Hidden` + 交回一次「已关」 | +//! +//! **`Open` 是"没有轨道"的稳态**:对话框开着可能几分钟,那不是动画,一帧都不该订阅。 + +use super::super::transition::{modal_spec, ModalFrame}; +use super::{leg_fraction, Pump}; + +/// 模态的状态机。 +/// +/// 为什么要状态机而不是一条进度:**弹窗开着的时候不能订阅帧**。`Open` 是一个"没有轨道"的 +/// 稳态([`Pump::is_animating`] 为假),只有起手与收尾那两段是动画。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum Modal { + /// 没有弹窗。 + Hidden, + /// 进场中。 + Opening { start_ms: f64 }, + /// 完全展开(稳态,不占帧)。 + Open, + /// 退场中。 + Closing { start_ms: f64 }, +} + +impl Pump { + /// 弹窗的**逻辑**开关(每帧 update 侧同步一次)。 + /// + /// - 开了 → 从 [`Modal::Hidden`] 起跑进场;已经在进/开着就不动。 + /// - 关了(不是走 [`Pump::begin_modal_close`] 的那条路,即框已经被别处直接关掉)→ + /// 立刻落 [`Modal::Hidden`](没有框可画,也就没有退场可言)。 + /// + /// 返回 `true` = 起跑了进场轨道。 + pub fn observe_modal(&mut self, open: bool) -> bool { + if open { + // 退场途中来了新框:**当场收尾**旧退场(`Hidden` + 待关),再走进场。不收尾的话 + // 新框会按递减的 progress 画(看起来是倒放),而且 `modal_closing()` 仍为真 ⇒ + // `on_dialog` 的闸门会把新框的按钮全吞掉(P2-2 的另两条后果)。 + if matches!(self.modal, Modal::Closing { .. }) { + self.modal = Modal::Hidden; + self.close_pending = true; + } + if matches!(self.modal, Modal::Hidden) { + if self.scale.is_off() { + self.modal = Modal::Open; + return false; + } + self.modal = Modal::Opening { + start_ms: self.start_now(), + }; + return true; + } + return false; + } + if !matches!(self.modal, Modal::Closing { .. }) { + self.modal = Modal::Hidden; + self.close_pending = false; + } + false + } + + /// 请求关掉弹窗:有动效就起跑退场(返回 `false`,真正的关闭由 [`Pump::take_modal_closed`] + /// 交回调用方),关着动效就返回 `true`(**立刻关,不留 0 时长动画**)。 + pub fn begin_modal_close(&mut self) -> bool { + if self.scale.is_off() || matches!(self.modal, Modal::Hidden) { + self.modal = Modal::Hidden; + self.close_pending = false; + return true; + } + self.modal = Modal::Closing { + start_ms: self.start_now(), + }; + false + } + + /// 退场是否已经走完(取走一次,之后返回 `false`)。 + pub fn take_modal_closed(&mut self) -> bool { + std::mem::take(&mut self.close_pending) + } + + /// 退场是否正在跑(这段时间里的弹窗消息要被吞掉:框已经在缩小,再点一次「删除」不该再生效)。 + pub fn modal_closing(&self) -> bool { + matches!(self.modal, Modal::Closing { .. }) + } + + /// 这一帧模态该画成什么样。 + pub fn modal_frame(&self) -> ModalFrame { + match self.modal { + Modal::Hidden => ModalFrame::HIDDEN, + Modal::Open => ModalFrame { + visible: true, + progress: 1.0, + }, + Modal::Opening { start_ms } => { + let spec = modal_spec(); + let t = leg_fraction( + self.elapsed_ms() - start_ms, + self.leg_ms(spec.enter.duration), + ); + ModalFrame { + visible: true, + progress: spec.enter.easing.sample(t), + } + } + Modal::Closing { start_ms } => { + let spec = modal_spec(); + let t = leg_fraction( + self.elapsed_ms() - start_ms, + self.leg_ms(spec.exit.duration), + ); + ModalFrame { + visible: true, + // 退场是**反向**走同一条线:1 → 0(与进场共用 `modal_scale` 的映射)。 + progress: 1.0 - spec.exit.easing.sample(t), + } + } + } + } +} diff --git a/crates/app-iced/src/components/motion/tests/mod.rs b/crates/app-iced/src/components/motion/tests/mod.rs index 533aacc..2566131 100644 --- a/crates/app-iced/src/components/motion/tests/mod.rs +++ b/crates/app-iced/src/components/motion/tests/mod.rs @@ -119,7 +119,7 @@ fn every_transition_is_built_from_registered_tokens() { #[test] fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() { let mut pump = standard(); - assert!(pump.observe(Some(2), None), "首次观察要起跑 Boot"); + assert!(pump.observe(Some(2), Some(1), None), "首次观察要起跑 Boot"); let frames = settle(&mut pump); assert!( (12..=20).contains(&frames), @@ -127,7 +127,7 @@ fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() { ); // 重跑一次,逐帧看端点与单调性 - pump.observe(Some(3), None); // 同级换页:medium2 = 300ms + pump.observe(Some(3), Some(1), None); // 同级换页:medium2 = 300ms let trace = samples(&mut pump); assert!(trace.len() >= 12, "中间态太少:{}", trace.len()); assert_eq!( @@ -163,10 +163,10 @@ fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() { #[test] fn the_old_page_moves_the_other_way_and_leaves_first() { let mut pump = standard(); - pump.observe(Some(2), None); + pump.observe(Some(2), Some(1), None); settle(&mut pump); - assert!(pump.observe(Some(3), None)); + assert!(pump.observe(Some(3), Some(1), None)); let first = pump.page_frame(); assert_eq!(first.enter, (12.0, 0.0)); assert_eq!(first.exit, (0.0, 0.0), "退场起点是 0"); @@ -201,23 +201,23 @@ fn the_old_page_moves_the_other_way_and_leaves_first() { #[test] fn direction_follows_navigation_depth() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); settle(&mut pump); // 向右推进(屏键 1 → 3) - pump.observe(Some(3), None); + pump.observe(Some(3), Some(1), None); assert_eq!(pump.kind(), Some(Kind::Peer { forward: true })); assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移"); settle(&mut pump); // 向左退回(3 → 1) - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); assert_eq!(pump.kind(), Some(Kind::Peer { forward: false })); assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像"); settle(&mut pump); // 进深层(同屏:列表 → 详情) - pump.observe(Some(1), Some("instance-7")); + pump.observe(Some(1), Some(0), Some("instance-7")); assert_eq!(pump.kind(), Some(Kind::Push)); let push = pump.page_frame(); assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)"); @@ -225,11 +225,42 @@ fn direction_follows_navigation_depth() { settle(&mut pump); // 出深层(详情 → 列表) - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); assert_eq!(pump.kind(), Some(Kind::Pop)); assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像"); } +/// **左右方向看导航轨序,不看屏键**(复审 P2-1 的守卫)。 +/// +/// 两处顺序不同:`Screen::ALL` 是 `… Content / Settings / Logs`,导航轨是 `… Logs / Settings / Ai`。 +/// 用屏键判方向会让 **Logs↔Settings 镜像**。 +/// +/// 抓得住:把 `Pump::observe` 的方向改回「比较屏键」(`new > old`)——本用例里 +/// Settings(键 4 / **序 3**) → Logs(键 5 / **序 2**) 会被判成向右推进,第一条断言立即红。 +/// 也抓得住「谁调了 `Screen::ALL` 的顺序」:这里**只引用轨序**,与那张表的声明顺序解耦。 +/// 抓不住:导航轨本身的视觉位置(那是 `shell/nav.rs` 的几何,归它的守卫)。 +#[test] +fn direction_comes_from_the_nav_rail_not_the_screen_table() { + let mut pump = standard(); + pump.observe(Some(4), Some(3), None); // Settings:屏键 4、轨序 3 + settle(&mut pump); + + // 轨序 3 → 2(向左)。若按屏键 4 → 5,会被判成向右 —— 那正是被修的 bug。 + pump.observe(Some(5), Some(2), None); + assert_eq!( + pump.kind(), + Some(Kind::Peer { forward: false }), + "Settings → Logs 在导航轨上是向左" + ); + assert_eq!(pump.page_frame().enter, (-12.0, 0.0)); + settle(&mut pump); + + // 反向:轨序 2 → 3 = 向右 + pump.observe(Some(4), Some(3), None); + assert_eq!(pump.kind(), Some(Kind::Peer { forward: true })); + assert_eq!(pump.page_frame().enter, (12.0, 0.0)); +} + /// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。 /// /// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()` @@ -241,13 +272,13 @@ fn none_scale_is_strictly_zero_not_merely_small() { assert!(pump.scale().is_off()); // 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`) - for (screen, detail) in [ - (Some(1), None), - (Some(2), None), - (Some(2), Some("instance-7")), - (Some(2), None), + for (screen, rank, detail) in [ + (Some(1), Some(0), None), + (Some(2), Some(1), None), + (Some(2), Some(1), Some("instance-7")), + (Some(2), Some(1), None), ] { - assert!(!pump.observe(screen, detail), "关掉动效时不许起跑"); + assert!(!pump.observe(screen, rank, detail), "关掉动效时不许起跑"); let frame = pump.page_frame(); assert_eq!(frame.enter.0, 0.0); assert_eq!(frame.enter.1, 0.0); @@ -282,11 +313,11 @@ fn none_scale_is_strictly_zero_not_merely_small() { #[test] fn repeated_paints_never_restart_the_animation() { let mut pump = standard(); - pump.observe(Some(3), Some("instance-9")); + pump.observe(Some(3), Some(1), Some("instance-9")); settle(&mut pump); for _ in 0..30 { - assert!(!pump.observe(Some(3), Some("instance-9"))); + assert!(!pump.observe(Some(3), Some(1), Some("instance-9"))); assert_eq!(pump.page_frame(), PageFrame::STILL); } assert!(!pump.is_animating()); @@ -296,13 +327,19 @@ fn repeated_paints_never_restart_the_animation() { #[test] fn screens_rendered_elsewhere_never_start_a_track() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); settle(&mut pump); - assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧"); + assert!( + !pump.observe(None, None, None), + "没有 pages 树可动 ⇒ 不许白开帧" + ); assert!(!pump.is_animating()); - assert!(pump.observe(Some(1), None), "从账户页回来仍然是换页"); + assert!( + pump.observe(Some(1), Some(0), None), + "从账户页回来仍然是换页" + ); assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向"); } @@ -314,10 +351,10 @@ fn screens_rendered_elsewhere_never_start_a_track() { #[test] fn a_second_navigation_mid_flight_takes_over_cleanly() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); settle(&mut pump); - pump.observe(Some(2), None); // 往右推进 + pump.observe(Some(2), Some(1), None); // 往右推进 for _ in 0..4 { pump.advance(FRAME_MS); // 走 3~5 帧就再点一次 } @@ -327,7 +364,7 @@ fn a_second_navigation_mid_flight_takes_over_cleanly() { "第一次过渡还在半路" ); - pump.observe(Some(1), None); // 立刻退回左边 + pump.observe(Some(1), Some(0), None); // 立刻退回左边 let restarted = pump.page_frame(); assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)"); assert_eq!( diff --git a/crates/app-iced/src/components/motion/tests/modal.rs b/crates/app-iced/src/components/motion/tests/modal.rs index dfb818b..b422688 100644 --- a/crates/app-iced/src/components/motion/tests/modal.rs +++ b/crates/app-iced/src/components/motion/tests/modal.rs @@ -7,6 +7,9 @@ use super::{settle, standard}; use unlucky_design::motion::{DurationToken, MotionScale}; /// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。 +/// +/// 抓得住:谁把某一腿改长/改短到不再"进长于出",或把起点缩放从令牌换回字面量。 +/// 抓不住:这套时长在真机上顺不顺眼(D30 的事)。 #[test] fn modal_spec_is_short_and_asymmetric() { let spec = modal_spec(); @@ -31,6 +34,7 @@ fn modal_spec_is_short_and_asymmetric() { /// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒ /// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed` /// 不返回 `true`(框永远关不掉)。 +/// 抓不住:缩放在**屏幕上**是从中心还是从某个角长出来的(命中与几何都没验,见报告)。 #[test] fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() { let mut pump = standard(); @@ -82,6 +86,9 @@ fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() { } /// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。 +/// +/// 抓得住:把 `observe_modal(false)` 改成也起退场 ⇒ `is_animating()` 为真,本条红。 +/// 抓不住:那条硬关路径在真机上多久发生一次(`state/refresh.rs` 的 settle 分支,未统计)。 #[test] fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() { let mut pump = standard(); @@ -96,10 +103,13 @@ fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() { } /// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。 +/// +/// 抓得住:把 `configure` 里 `tracks.clear()` 那段删掉(轨道留着 ⇒ 仍在占帧)。 +/// 抓不住:真实用户什么时候会中途改系统开关(那要改系统设置,见报告的未验清单)。 #[test] fn switching_to_none_mid_flight_settles_immediately() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); pump.advance(FRAME_MS); assert!(pump.is_animating()); @@ -145,11 +155,14 @@ fn reduced_motion_only_triggers_on_an_explicit_reduced() { // ------------------------------------------------------------------ 取证相位 /// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。 +/// +/// 抓得住:把 `start_now`/`elapsed_ms` 的钉住分支删掉(进度会随时间漂,序列图不再可复现)。 +/// 抓不住:**出图本身**——这条只证明"相位恒定",图得另外拍(见报告的未验清单)。 #[test] fn pinned_phase_freezes_the_clock() { let mut pump = Pump::new(); pump.configure(MotionScale::STANDARD, Some(150.0)); - pump.observe(Some(1), None); // 300ms 档 ⇒ 钉在 150 = 一半 + pump.observe(Some(1), Some(0), None); // 300ms 档 ⇒ 钉在 150 = 一半 for _ in 0..10 { pump.advance(FRAME_MS); @@ -163,3 +176,73 @@ fn pinned_phase_freezes_the_clock() { ); assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)"); } + +/// **退场途中来了新框**:当场收尾旧退场、重新走进场(复审 P2-2)。 +/// +/// 覆盖三条后果里的 ①(不再按递减进度倒放)与 ③(退场到期那拍不会整层消失一帧); +/// ②(闸门把新框的按钮全吞)由 `!modal_closing()` 间接证明——闸门判据就是它。 +/// +/// 抓得住:把 `observe_modal` 里「`Closing` 先当场收尾」那段删掉 ⇒ 本条红(progress 递减、 +/// 且 `modal_closing()` 仍为真)。 +/// 抓不住:真实 UI 上「框 A 缩到一半、框 B 从 0.96 长起来」那一帧的观感(D30)。 +#[test] +fn a_new_modal_during_the_exit_restarts_the_entrance() { + let mut pump = standard(); + assert!(pump.observe_modal(true), "框 A 进场"); + settle(&mut pump); + assert!(!pump.begin_modal_close(), "关 A:退场开始"); + for _ in 0..3 { + pump.advance(FRAME_MS); + } + let halfway = pump.modal_frame().progress; + assert!( + halfway > 0.0 && halfway < 1.0, + "退场走到一半,实测 {halfway}" + ); + + // 框 B 这时弹出来 + assert!(pump.observe_modal(true), "新框必须起跑新的进场轨道"); + assert_eq!( + pump.modal_frame().progress, + 0.0, + "①重新走进场:进度从 0 开始,而不是接着倒放" + ); + assert!(!pump.modal_closing(), "②闸门必须松开(否则新框按钮全被吞)"); + assert!( + pump.take_modal_closed(), + "旧退场被当场收尾,交回一次「已关」" + ); + + // ③之后每一帧都必须可见(不许闪一帧空的模态层) + let mut frames = 0; + while pump.is_animating() { + assert!(pump.modal_frame().visible, "③中途不许整层消失"); + pump.advance(FRAME_MS); + frames += 1; + } + assert!(frames > 0); + assert_eq!(pump.modal_frame().progress, 1.0, "进场必须走到完全展开"); +} + +/// **产品入口真的把系统偏好合成进来**(复审 P2-5 要求的「故意走真探针」那条)。 +/// +/// 断言的是**自洽**而不是某个具体值:`configure` 的结果必须等于 +/// `effective_scale(STANDARD, ReduceMotion::probe(), false)`。于是在「系统关掉动画」的机器上 +/// 它仍然是绿的(那时两边都是 `NONE`),而一旦有人把产品入口接回「直接用传入档位」就会红。 +/// +/// 抓得住:`motion::configure` 里那一行 `effective_scale(...)` 被去掉。 +/// 抓不住:宿主机上「用户设的那一格到底读对没有」(那要改系统设置,见报告的未验清单)。 +#[test] +fn configure_goes_through_the_real_os_probe() { + use crate::platform::motion::ReduceMotion; + + reset(); + configure(MotionScale::STANDARD, None); // 产品入口:不绕过探针 + let expected = effective_scale(MotionScale::STANDARD, ReduceMotion::probe(), false); + assert_eq!( + scale(), + expected, + "产品入口必须真的把系统偏好合成进来(读不到 = 不改档)" + ); + reset(); +} diff --git a/crates/app-iced/src/components/motion/tests/timeline.rs b/crates/app-iced/src/components/motion/tests/timeline.rs index 24f3b63..ee77df2 100644 --- a/crates/app-iced/src/components/motion/tests/timeline.rs +++ b/crates/app-iced/src/components/motion/tests/timeline.rs @@ -13,10 +13,11 @@ use unlucky_design::motion::{DurationToken, MotionScale}; /// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。 /// /// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。 +/// 抓不住:整窗重绘次数(那跟随渲染器,见 `FRAME_MS` 的文档)。 #[test] fn a_finished_animation_stops_asking_for_frames() { let mut pump = standard(); - assert!(pump.observe(Some(1), None)); + assert!(pump.observe(Some(1), Some(0), None)); assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)"); settle(&mut pump); @@ -31,10 +32,13 @@ fn a_finished_animation_stops_asking_for_frames() { } /// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。 +/// +/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。 +/// 抓不住:**整窗重绘**频率——那跟随渲染器,与这条闸门无关。 #[test] fn frames_are_capped_at_sixty_per_second() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); let mut advanced = 0; for _ in 0..200 { @@ -55,10 +59,13 @@ fn frames_are_capped_at_sixty_per_second() { } /// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。 +/// +/// 抓得住:把 `MAX_STEP_MS` 的夹取删掉(一次 60 s 的 dt 会把轨道当场杀掉 ⇒ 动画一帧走完)。 +/// 抓不住:真机上「空闲醒来第一帧的 dt 到底多长」(那要真窗口,见报告的未验清单)。 #[test] fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { let mut pump = standard(); - pump.observe(Some(1), None); + pump.observe(Some(1), Some(0), None); assert!(pump.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt assert!(pump.is_animating(), "一次超长 dt 不许把轨道当场删掉"); @@ -73,18 +80,19 @@ fn a_stale_baseline_cannot_kill_the_track_in_one_sample() { /// /// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快, /// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。 +/// 抓不住:**观感上"快一倍"是否合适**(那是 D30 的截图/录屏判断,不是断言)。 #[test] fn half_scale_halves_the_duration_not_the_speed() { assert_eq!(MotionScale::HALF.factor(), 0.5); assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0); let mut full = standard(); - full.observe(Some(1), None); + full.observe(Some(1), Some(0), None); let full_frames = settle(&mut full); let mut half = Pump::new(); half.configure(MotionScale::HALF, None); - half.observe(Some(1), None); + half.observe(Some(1), Some(0), None); let half_frames = settle(&mut half); assert!(half_frames > 0 && full_frames > 0);