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:
1 parent
4a77f1b421
commit
1edf645c2f
9 files changed
+446
-274
No files matched your search
@@ -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())));
|
||||
}
|
||||
|
||||
+23
-108
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user