fix(app-iced): 复审 p2 五条(方向取轨序 / 退场重开 / 遮罩口径 / 恒真断言 / 探针隔离)

- 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 行硬线)。
This commit is contained in:
AskaEth committed 2026-11-12 10:50:03 +08:00
1 parent 4a77f1b421
commit 1edf645c2f
9 files changed
+446 -274

No files matched your search

+8 -3
View File
@@ -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] {
+11 -10
View File
@@ -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` / 自己推时间轴
+106 -120
View File
@@ -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<u64> {
.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<u8> {
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<Option<u64>> = [
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<pages::Model>`,结构上装不下第二份(`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(), "退场结束后不许留残影");
+16 -3
View File
@@ -94,6 +94,17 @@ pub fn configure(scale: MotionScale, pin: Option<f64>) {
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<f64>) {
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<u64>, detail: Option<&str>) {
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail));
pub fn observe(screen: Option<u64>, rank: Option<u8>, 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())));
}
@@ -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<u64>,
/// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。
rank: Option<u8>,
detail: Option<String>,
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<u64>, detail: Option<&str>) -> bool {
pub fn observe(&mut self, screen: Option<u64>, rank: Option<u8>, 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` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
@@ -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),
}
}
}
}
}
@@ -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!(
@@ -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();
}
@@ -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);