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

+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);