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
@@ -105,8 +105,12 @@ pub(super) fn backdrop_color(ui: &Ui) -> Color {
|
|||||||
///
|
///
|
||||||
/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级**
|
/// **为什么是"插值颜色"而不是"调 α"**:D27 禁 CPU 渲染路径上的面板级半透明(D36 只对**窗口级**
|
||||||
/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经
|
/// 统一 α 开过口子,面板 α 仍强制 1.0)。所以「淡入」不能靠 `Color { a: 0.1 }`,只能把**已经
|
||||||
/// 合成好的不透明色**从画布色插值到 scrim 色:`0` 时与背后画布同色(看不见)、`1` 时是原遮罩,
|
/// 合成好的不透明色从画布色插值到 scrim 色,全程 `a = 1.0`,两个后端出图一致。
|
||||||
/// 全程 `a = 1.0`,两个后端出图一致。`1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。
|
/// `1` 那一端必须**逐位等于**静息遮罩色,否则出图基线会漂。
|
||||||
|
///
|
||||||
|
/// ⚠️ **口径(复审 P2-3 订正)**:`0` 时这一层是**整块画布色的不透明矩形**,它把页面内容
|
||||||
|
/// **整块遮住**(不是"透明所以看不见",也不是"看得到页面")——D27 禁面板 α,所以只有这一种
|
||||||
|
/// 可行实现。观感是「画面先被画布色盖住、随进度变暗到 scrim」,不是「页面透过淡遮罩隐隐可见」。
|
||||||
pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color {
|
pub(super) fn backdrop_color_at(ui: &Ui, progress: f32) -> Color {
|
||||||
let t = progress.clamp(0.0, 1.0);
|
let t = progress.clamp(0.0, 1.0);
|
||||||
if t >= 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.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 遮罩:`0` 是画布色(看不见)、`1` 是原遮罩(逐位相同)、全程不透明且单调变暗。
|
/// 遮罩:`0` 是整块画布色(**把页面内容盖住**,不是透明)、`1` 是原遮罩(逐位相同)、
|
||||||
|
/// 全程不透明且单调变暗。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() {
|
fn the_scrim_fades_by_lerping_opaque_colors_never_by_alpha() {
|
||||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
|
||||||
|
///
|
||||||
|
/// 定档走 `motion::configure_for_test`(复审 P2-5):产品入口读宿主机「减少动效」开关,
|
||||||
|
/// 用例在关了系统动画的机器上会红;真探针的接线另有专门用例守。
|
||||||
fn headless() -> App {
|
fn headless() -> App {
|
||||||
App::with_presenter(
|
App::with_presenter(
|
||||||
Options::default(),
|
Options::default(),
|
||||||
@@ -148,7 +151,7 @@ mod tests {
|
|||||||
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
||||||
fn idle_app() -> App {
|
fn idle_app() -> App {
|
||||||
motion::reset();
|
motion::reset();
|
||||||
motion::configure(MotionScale::STANDARD, None);
|
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||||
let mut app = headless();
|
let mut app = headless();
|
||||||
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
|
// 有窗口才谈得上动效(`sync_motion` 在没有窗口时直接返回,见它的文档)
|
||||||
app.window = Some(iced::window::Id::unique());
|
app.window = Some(iced::window::Id::unique());
|
||||||
@@ -238,7 +241,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn without_a_window_nothing_animates() {
|
fn without_a_window_nothing_animates() {
|
||||||
motion::reset();
|
motion::reset();
|
||||||
motion::configure(MotionScale::STANDARD, None);
|
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||||
let mut app = headless();
|
let mut app = headless();
|
||||||
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
|
assert!(app.window.is_none(), "前提:还没拿到窗口句柄");
|
||||||
let _ = app.update(Message::Boot);
|
let _ = app.update(Message::Boot);
|
||||||
@@ -262,7 +265,7 @@ mod tests {
|
|||||||
app.window = Some(iced::window::Id::unique());
|
app.window = Some(iced::window::Id::unique());
|
||||||
let _ = app.update(Message::Boot);
|
let _ = app.update(Message::Boot);
|
||||||
app.watch_until = None;
|
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)));
|
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||||
@@ -278,11 +281,10 @@ mod tests {
|
|||||||
motion::reset();
|
motion::reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,所以不会出现「动画与 Busy 各订一条」。
|
/// 帧订阅在**产品接线**里只出现一处:五条理由共享同一个布尔,不会出现「动画与 Busy 各订一条」。
|
||||||
///
|
///
|
||||||
/// 口径是**全仓 `src/**`**(复审 r1 备注:只扫 `subscription.rs` 抓不到别人另开订阅),只看
|
/// 口径是**全仓 `src/**`**(只扫本文件抓不到别人另开订阅),只看代码行。唯一允许的第二个文件
|
||||||
/// 代码行(注释/文档里提到这个符号不算)。唯一允许的第二个文件是 `pages/harness.rs`——
|
/// 是 `pages/harness.rs`——那是 `pages_demo` 这条**独立 example** 自己的 app(不进产品路径)。
|
||||||
/// 那是 `pages_demo` 这条**独立 example** 自己的 iced app(不接 `App`、不进产品路径)。
|
|
||||||
#[test]
|
#[test]
|
||||||
fn there_is_exactly_one_frame_subscription() {
|
fn there_is_exactly_one_frame_subscription() {
|
||||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
@@ -342,9 +344,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
|
/// 「**页面拿不到时长**」的源码守卫(M256 取代 M192 的 `Slot` 三条断言)。
|
||||||
///
|
/// 口径:`pages/**` 的产品代码不许出现时长的任何入口(`DurationToken` / `page_spec` /
|
||||||
/// 口径:`pages/**` 的产品代码里不许出现时长的任何入口(`DurationToken` / `page_spec` /
|
/// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档由
|
||||||
/// `modal_spec` / `FRAME_MS` / `advance(`):页面只能说「我要换页了」,用哪一档、多久由
|
|
||||||
/// `components/motion/transition.rs` 那张表决定(D31)。
|
/// `components/motion/transition.rs` 那张表决定(D31)。
|
||||||
///
|
///
|
||||||
/// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴
|
/// **抓得住**:页面里写 `DurationToken::Medium2` / `motion::FRAME_MS` / 自己推时间轴
|
||||||
|
|||||||
@@ -9,20 +9,16 @@
|
|||||||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
//! 也就是说**订阅表的求值早于视图**。第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,
|
||||||
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
//! `frames()` 要等下一条消息才打开,而那一帧的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,
|
||||||
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
//! 用户看到的是一次 12/8px 的偏移闪跳(截图通道因为 `has_screenshot()` 恒真,**结构上**不可能
|
||||||
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
//! 暴露这条)。所以起跑搬到这里:`App::update` 处理完每条消息后调 [`App::sync_motion`]。
|
||||||
//! **结构上**不可能暴露这条。
|
|
||||||
//!
|
|
||||||
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
|
||||||
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
|
||||||
//!
|
//!
|
||||||
//! ## 标识从哪来
|
//! ## 标识从哪来
|
||||||
//!
|
//!
|
||||||
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」(账户页 /
|
||||||
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
//! AI 独立页不归,它们之间切换不起跑),`nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,
|
||||||
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
//! 不看屏键大小——见 [`nav_rank`])。
|
||||||
|
|
||||||
use unlucky_presenter::router::Route;
|
use unlucky_presenter::router::Route;
|
||||||
|
|
||||||
@@ -30,6 +26,8 @@ use crate::app::components::motion;
|
|||||||
use crate::pages;
|
use crate::pages;
|
||||||
|
|
||||||
use super::boot_env::MotionFixture;
|
use super::boot_env::MotionFixture;
|
||||||
|
use crate::app::message::nav_item_of;
|
||||||
|
use crate::shell::NavItem;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -39,7 +37,9 @@ impl App {
|
|||||||
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
|
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
|
||||||
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
|
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
|
||||||
// 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。
|
// 那一份画面"。同一拍里置 `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.dialog_model = None;
|
||||||
self.view_dirty = true;
|
self.view_dirty = true;
|
||||||
}
|
}
|
||||||
@@ -57,14 +57,13 @@ impl App {
|
|||||||
Route::Detail(id) => Some(id.as_str()),
|
Route::Detail(id) => Some(id.as_str()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
motion::observe(screen, detail);
|
motion::observe(screen, nav_rank(route), detail);
|
||||||
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
|
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
|
||||||
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
|
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
|
||||||
motion::observe_modal(self.state.dialogs.current().is_some());
|
motion::observe_modal(self.state.dialogs.current().is_some());
|
||||||
|
|
||||||
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**。
|
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
|
||||||
// 这条是硬要求:`previous_view` 是一份完整的页面模型,留着就是「每换一次页多一份」的
|
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
|
||||||
// 线性增长——长时间常驻的启动器上这是明确的缺陷,不是"小浪费"。
|
|
||||||
if !motion::needs_frames() {
|
if !motion::needs_frames() {
|
||||||
self.previous_view = None;
|
self.previous_view = None;
|
||||||
}
|
}
|
||||||
@@ -151,6 +150,24 @@ fn screen_key(route: &Route) -> Option<u64> {
|
|||||||
.map(|index| index as u64 + 1)
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn idle_app() -> App {
|
||||||
motion::reset();
|
motion::reset();
|
||||||
motion::configure(MotionScale::STANDARD, None);
|
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||||
let mut app = headless();
|
let mut app = headless();
|
||||||
app.window = Some(iced::window::Id::unique());
|
app.window = Some(iced::window::Id::unique());
|
||||||
let _ = app.update(Message::Boot);
|
let _ = app.update(Message::Boot);
|
||||||
app.watch_until = None;
|
app.watch_until = None;
|
||||||
run_until_idle();
|
run_until_idle();
|
||||||
// 再走一条消息:`sync_motion` 在这一拍把启动期那次换页的 `previous_view` 烧掉
|
// 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。
|
||||||
// (首屏从「构造时的模型」换到真路由,本身就是一次内容标识变化)。
|
|
||||||
let _ = app.update(Message::Frame);
|
let _ = app.update(Message::Frame);
|
||||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
|
||||||
|
///
|
||||||
|
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
|
||||||
|
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
|
||||||
|
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
|
||||||
|
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_five_screens_get_a_key() {
|
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
|
||||||
let keys: Vec<Option<u64>> = [
|
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
|
||||||
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)]);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
keys[5],
|
keyed(&Route::Detail("survival".to_string())),
|
||||||
Some(1),
|
(Some(1), Some(0)),
|
||||||
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
"详情归「实例」"
|
||||||
);
|
);
|
||||||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
assert_eq!(
|
||||||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
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]
|
#[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();
|
let mut app = idle_app();
|
||||||
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
|
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
|
||||||
|
|
||||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||||
assert!(
|
|
||||||
app.previous_view.is_some(),
|
|
||||||
"换页那一拍必须留下旧页(退场要画它)"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.previous_view.as_ref().map(|model| model.screen),
|
app.previous_view.as_ref().map(|model| model.screen),
|
||||||
Some(pages::Screen::Instances),
|
Some(pages::Screen::Instances),
|
||||||
"旧页必须是**切换前**那一屏"
|
"换页那一拍必须留下**切换前**那一屏"
|
||||||
);
|
);
|
||||||
|
|
||||||
run_until_idle();
|
run_until_idle();
|
||||||
// 下一拍消息(真机上就是最后一帧)收尾。
|
|
||||||
let _ = app.update(Message::Frame);
|
let _ = app.update(Message::Frame);
|
||||||
assert!(
|
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
|
||||||
app.previous_view.is_none(),
|
|
||||||
"过渡结束必须当拍丢掉旧页模型(不许随换页次数线性增长)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// **连续多次换页也只保有 1 份旧模型**(门槛 B 的另一半)。
|
// 快速连点:中间只走 4 帧就点下一次
|
||||||
#[test]
|
for route in [Route::Content, Route::Settings, Route::Instances] {
|
||||||
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 {
|
|
||||||
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
|
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
|
||||||
// 中间只走 4 帧再点下一次(模拟快速连点导航轨)
|
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
motion::advance_by(FRAME_MS);
|
motion::advance_by(FRAME_MS);
|
||||||
}
|
}
|
||||||
let count = usize::from(app.previous_view.is_some());
|
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
|
||||||
assert!(count <= 1, "同一时刻最多只有 1 份旧页模型,实测 {count}");
|
|
||||||
}
|
}
|
||||||
run_until_idle();
|
run_until_idle();
|
||||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
|
|
||||||
run_until_idle();
|
|
||||||
let _ = app.update(Message::Frame);
|
let _ = app.update(Message::Frame);
|
||||||
assert!(
|
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
|
||||||
app.previous_view.is_none(),
|
|
||||||
"连续 5 次换页之后仍应是 None(不是攒了 5 份)"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
|
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
|
||||||
///
|
///
|
||||||
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成"每次刷新都换"——那样旧页会跟着
|
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
|
||||||
/// presenter 变,画面上就是两个页面同时动内容。
|
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
|
||||||
#[test]
|
#[test]
|
||||||
fn the_previous_page_is_a_frozen_snapshot() {
|
fn the_previous_page_is_a_frozen_snapshot() {
|
||||||
let mut app = idle_app();
|
let mut app = idle_app();
|
||||||
@@ -306,9 +316,8 @@ mod tests {
|
|||||||
.map(|model| (model.screen, model.window_width));
|
.map(|model| (model.screen, model.window_width));
|
||||||
assert!(before.is_some());
|
assert!(before.is_some());
|
||||||
|
|
||||||
// 过渡期间来一次「同屏刷新」(主题/下载进度这一类的形状:不换屏)
|
|
||||||
app.view_dirty = true;
|
app.view_dirty = true;
|
||||||
app.refresh_view();
|
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
|
||||||
motion::advance_by(FRAME_MS);
|
motion::advance_by(FRAME_MS);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -320,20 +329,14 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **退场窗口内那个框已经关掉,且不可再交互**(tower 的硬要求)。
|
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
|
||||||
///
|
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
|
||||||
/// 四件事分别断言:①逻辑状态当拍就空;②画面还在退场(`dialog_model` 仍 `Some` ⇒ 不是硬跳);
|
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条独立路径**也挡住(`escape` 的闸门)。
|
||||||
/// ③这 150ms 内再点一次**什么都不发生**(`on_dialog` 的闸门);④**Esc 那条独立路径**也挡住。
|
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
|
||||||
///
|
|
||||||
/// **抓得住**:把 `on_dialog` 顶部的 `if motion::modal_closing()` 删掉(第二次点击会走进
|
|
||||||
/// `close(app)`,`refreshes` 涨一次 ⇒ 红);或让逻辑关闭延后(①红);或让退场直接清模型(②红)。
|
|
||||||
/// **抓不住**:几何级的命中测试——「点在那个坐标上会不会命中幽灵框」。本仓的单元测试没有
|
|
||||||
/// iced 的布局树,`zoom.rs` 里的缩放也**不改**命中矩形(记在它自己的"已知局限"里)。
|
|
||||||
/// 这一条由闸门保证:**无论命中在哪,退场期间的弹窗消息一律不生效**。
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
|
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
|
||||||
use crate::app::dialog::Message as DialogMessage;
|
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};
|
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||||
|
|
||||||
let mut app = idle_app();
|
let mut app = idle_app();
|
||||||
@@ -348,46 +351,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
app.view_dirty = true;
|
app.view_dirty = true;
|
||||||
app.refresh_view();
|
app.refresh_view();
|
||||||
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机的顺序一致)
|
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
|
||||||
let _ = app.update(Message::Frame);
|
let _ = app.update(Message::Frame);
|
||||||
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
|
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
|
||||||
run_until_idle(); // 进场跑完(`Open` 稳态)
|
run_until_idle();
|
||||||
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
|
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
|
||||||
|
|
||||||
// 用户点「取消」:逻辑当拍关闭,画面开始退场
|
|
||||||
let _ = on_dialog(&mut app, DialogMessage::Secondary);
|
let _ = on_dialog(&mut app, DialogMessage::Secondary);
|
||||||
assert!(
|
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
|
||||||
app.state.dialogs.current().is_none(),
|
assert!(motion::modal_closing(), "②画面还在退场");
|
||||||
"退场窗口内逻辑上必须**已经**关掉(任何状态读取者都该看到没有框)"
|
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
|
||||||
);
|
|
||||||
assert!(motion::modal_closing(), "画面还在退场");
|
|
||||||
assert!(
|
|
||||||
app.dialog_model.is_some(),
|
|
||||||
"退场窗口内仍画得到旧框(否则就是一次硬跳)"
|
|
||||||
);
|
|
||||||
assert!(motion::needs_frames(), "退场要帧");
|
|
||||||
|
|
||||||
// 150ms 内再点一次原来的主按钮位置:必须被闸门吞掉
|
|
||||||
let refreshes = app.refreshes;
|
let refreshes = app.refreshes;
|
||||||
let _ = on_dialog(&mut app, DialogMessage::Primary);
|
let _ = on_dialog(&mut app, DialogMessage::Primary);
|
||||||
assert_eq!(
|
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
|
||||||
app.refreshes, refreshes,
|
|
||||||
"退场窗口内的点击必须被吞掉(连一次重画都不该发生)"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
app.state.dialogs.current().is_none(),
|
app.state.dialogs.current().is_none(),
|
||||||
"幽灵点击不许复活那个框"
|
"③幽灵点击不许复活那个框"
|
||||||
);
|
);
|
||||||
assert!(app.dialog_model.is_some(), "也不许把它重新打开");
|
|
||||||
// **键盘入口单列一条**:`Esc` 不走 `on_dialog`(它是 `Message::Agent(Key)` 那条路),
|
|
||||||
// 必须在 `escape` 里单独挡住,否则退场那 150ms 里 Esc 会落到 AI 栏去收起面板。
|
|
||||||
assert!(
|
assert!(
|
||||||
crate::app::update::dialog::escape(&mut app),
|
escape(&mut app),
|
||||||
"退场窗口内的 Esc 也必须被吃掉(不许落到 AI 栏)"
|
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
|
||||||
);
|
);
|
||||||
assert!(app.state.dialogs.current().is_none());
|
|
||||||
|
|
||||||
// 退场走完:下一拍的 `sync_motion` 把它丢掉,帧泵停
|
|
||||||
run_until_idle();
|
run_until_idle();
|
||||||
let _ = app.update(Message::Frame);
|
let _ = app.update(Message::Frame);
|
||||||
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
|
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ pub fn configure(scale: MotionScale, pin: Option<f64>) {
|
|||||||
PUMP.with(|pump| pump.borrow_mut().configure(scale, pin));
|
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`):多帧序列图要在同一台机器上重复拍到同一相位,
|
/// `pinned` = 取证相位钉住(`ULL_MOTION_AT`):多帧序列图要在同一台机器上重复拍到同一相位,
|
||||||
@@ -123,13 +134,15 @@ pub fn needs_frames() -> bool {
|
|||||||
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
/// 页主体的内容**标识**:**每条消息处理完**调一次(`App::sync_motion`),标识变了就此起跑。
|
||||||
///
|
///
|
||||||
/// `screen` 是 `pages::Screen` 的稳定键(`None` = 这一屏不归 pages 画:账户页 / AI 独立页),
|
/// `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` 是几分钟前的值,
|
/// 起跑时**顺带把帧基准重置到「现在」**:空闲期没有帧,`LAST_FRAME` 是几分钟前的值,
|
||||||
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
|
/// 不重置的话第一帧仍会按整段空闲时长结算(第二道兜底是 [`pump::MAX_STEP_MS`])。
|
||||||
pub fn observe(screen: Option<u64>, detail: Option<&str>) {
|
pub fn observe(screen: Option<u64>, rank: Option<u8>, detail: Option<&str>) {
|
||||||
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, detail));
|
let started = PUMP.with(|pump| pump.borrow_mut().observe(screen, rank, detail));
|
||||||
if started {
|
if started {
|
||||||
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
|
LAST_FRAME.with(|last| last.set(Some(Instant::now())));
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-108
@@ -15,9 +15,12 @@
|
|||||||
|
|
||||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||||
|
|
||||||
|
mod modal;
|
||||||
|
|
||||||
use super::transition::{
|
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 次/秒)。
|
/// 逐帧**推进时间轴**的最短间隔(16 ms ≈ 60 次/秒)。
|
||||||
///
|
///
|
||||||
@@ -45,22 +48,6 @@ struct PageTrack {
|
|||||||
start_ms: f64,
|
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` 按档位打开,而
|
/// **默认是关的**(`MotionScale::NONE`):产品启动时由 `App::on_boot` 按档位打开,而
|
||||||
@@ -77,6 +64,8 @@ pub struct Pump {
|
|||||||
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
/// 退场走完了、等调用方真正关掉那个框([`Pump::take_modal_closed`] 取走一次)。
|
||||||
close_pending: bool,
|
close_pending: bool,
|
||||||
screen: Option<u64>,
|
screen: Option<u64>,
|
||||||
|
/// 该屏在**导航轨**上的次序(`shell::nav::NavItem::ALL` 下标;`None` = 不在轨上)。
|
||||||
|
rank: Option<u8>,
|
||||||
detail: Option<String>,
|
detail: Option<String>,
|
||||||
painted: bool,
|
painted: bool,
|
||||||
samples: u64,
|
samples: u64,
|
||||||
@@ -94,6 +83,7 @@ impl Pump {
|
|||||||
modal: Modal::Hidden,
|
modal: Modal::Hidden,
|
||||||
close_pending: false,
|
close_pending: false,
|
||||||
screen: None,
|
screen: None,
|
||||||
|
rank: None,
|
||||||
detail: None,
|
detail: None,
|
||||||
painted: false,
|
painted: false,
|
||||||
samples: 0,
|
samples: 0,
|
||||||
@@ -110,6 +100,7 @@ impl Pump {
|
|||||||
self.pin = pin;
|
self.pin = pin;
|
||||||
self.painted = false;
|
self.painted = false;
|
||||||
self.screen = None;
|
self.screen = None;
|
||||||
|
self.rank = None;
|
||||||
self.detail = None;
|
self.detail = None;
|
||||||
self.pending_ms = 0.0;
|
self.pending_ms = 0.0;
|
||||||
self.modal = Modal::Hidden;
|
self.modal = Modal::Hidden;
|
||||||
@@ -217,16 +208,25 @@ impl Pump {
|
|||||||
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
|
/// `screen == None`(账户页 / AI 独立页:它们不走 `pages::render`)**只记标识、不起跑**。
|
||||||
///
|
///
|
||||||
/// 返回 `true` = 真的起跑了新轨道(调用方要重置帧基准,见 [`MAX_STEP_MS`])。
|
/// 返回 `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 {
|
let kind = if !self.painted {
|
||||||
self.painted = true;
|
self.painted = true;
|
||||||
screen.map(|_| Kind::Boot)
|
screen.map(|_| Kind::Boot)
|
||||||
} else if self.screen != screen {
|
} else if self.screen != screen {
|
||||||
screen.map(|new| match self.screen {
|
match (self.screen, screen, self.rank, rank) {
|
||||||
Some(old) => Kind::Peer { forward: new > old },
|
// 左右方向 = **用户在导航轨上看到的次序**(`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 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
|
// 从「不归 pages 画」的屏回来:没有旧页可退,按首屏那一档的入场方向。
|
||||||
None => Kind::Boot,
|
(_, Some(_), _, _) => Some(Kind::Boot),
|
||||||
})
|
_ => None,
|
||||||
|
}
|
||||||
} else if screen.is_some() && self.detail.as_deref() != detail {
|
} else if screen.is_some() && self.detail.as_deref() != detail {
|
||||||
Some(match (self.detail.is_some(), detail.is_some()) {
|
Some(match (self.detail.is_some(), detail.is_some()) {
|
||||||
(false, true) => Kind::Push,
|
(false, true) => Kind::Push,
|
||||||
@@ -239,6 +239,7 @@ impl Pump {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.screen = screen;
|
self.screen = screen;
|
||||||
|
self.rank = rank;
|
||||||
if self.detail.as_deref() != detail {
|
if self.detail.as_deref() != detail {
|
||||||
self.detail = detail.map(str::to_owned);
|
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` = 这一帧真的推进了(进度变了 ⇒ 要重画)。
|
/// 推进时间轴。返回 `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]
|
#[test]
|
||||||
fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
|
fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
|
||||||
let mut pump = standard();
|
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);
|
let frames = settle(&mut pump);
|
||||||
assert!(
|
assert!(
|
||||||
(12..=20).contains(&frames),
|
(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);
|
let trace = samples(&mut pump);
|
||||||
assert!(trace.len() >= 12, "中间态太少:{}", trace.len());
|
assert!(trace.len() >= 12, "中间态太少:{}", trace.len());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -163,10 +163,10 @@ fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn the_old_page_moves_the_other_way_and_leaves_first() {
|
fn the_old_page_moves_the_other_way_and_leaves_first() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(2), None);
|
pump.observe(Some(2), Some(1), None);
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
assert!(pump.observe(Some(3), None));
|
assert!(pump.observe(Some(3), Some(1), None));
|
||||||
let first = pump.page_frame();
|
let first = pump.page_frame();
|
||||||
assert_eq!(first.enter, (12.0, 0.0));
|
assert_eq!(first.enter, (12.0, 0.0));
|
||||||
assert_eq!(first.exit, (0.0, 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]
|
#[test]
|
||||||
fn direction_follows_navigation_depth() {
|
fn direction_follows_navigation_depth() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(1), None);
|
pump.observe(Some(1), Some(0), None);
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
// 向右推进(屏键 1 → 3)
|
// 向右推进(屏键 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.kind(), Some(Kind::Peer { forward: true }));
|
||||||
assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移");
|
assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移");
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
// 向左退回(3 → 1)
|
// 向左退回(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.kind(), Some(Kind::Peer { forward: false }));
|
||||||
assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像");
|
assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像");
|
||||||
settle(&mut pump);
|
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));
|
assert_eq!(pump.kind(), Some(Kind::Push));
|
||||||
let push = pump.page_frame();
|
let push = pump.page_frame();
|
||||||
assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)");
|
assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)");
|
||||||
@@ -225,11 +225,42 @@ fn direction_follows_navigation_depth() {
|
|||||||
settle(&mut pump);
|
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.kind(), Some(Kind::Pop));
|
||||||
assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像");
|
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**(不是"很小")。
|
/// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。
|
||||||
///
|
///
|
||||||
/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()`
|
/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()`
|
||||||
@@ -241,13 +272,13 @@ fn none_scale_is_strictly_zero_not_merely_small() {
|
|||||||
assert!(pump.scale().is_off());
|
assert!(pump.scale().is_off());
|
||||||
|
|
||||||
// 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`)
|
// 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`)
|
||||||
for (screen, detail) in [
|
for (screen, rank, detail) in [
|
||||||
(Some(1), None),
|
(Some(1), Some(0), None),
|
||||||
(Some(2), None),
|
(Some(2), Some(1), None),
|
||||||
(Some(2), Some("instance-7")),
|
(Some(2), Some(1), Some("instance-7")),
|
||||||
(Some(2), None),
|
(Some(2), Some(1), None),
|
||||||
] {
|
] {
|
||||||
assert!(!pump.observe(screen, detail), "关掉动效时不许起跑");
|
assert!(!pump.observe(screen, rank, detail), "关掉动效时不许起跑");
|
||||||
let frame = pump.page_frame();
|
let frame = pump.page_frame();
|
||||||
assert_eq!(frame.enter.0, 0.0);
|
assert_eq!(frame.enter.0, 0.0);
|
||||||
assert_eq!(frame.enter.1, 0.0);
|
assert_eq!(frame.enter.1, 0.0);
|
||||||
@@ -282,11 +313,11 @@ fn none_scale_is_strictly_zero_not_merely_small() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn repeated_paints_never_restart_the_animation() {
|
fn repeated_paints_never_restart_the_animation() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(3), Some("instance-9"));
|
pump.observe(Some(3), Some(1), Some("instance-9"));
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
for _ in 0..30 {
|
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_eq!(pump.page_frame(), PageFrame::STILL);
|
||||||
}
|
}
|
||||||
assert!(!pump.is_animating());
|
assert!(!pump.is_animating());
|
||||||
@@ -296,13 +327,19 @@ fn repeated_paints_never_restart_the_animation() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn screens_rendered_elsewhere_never_start_a_track() {
|
fn screens_rendered_elsewhere_never_start_a_track() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(1), None);
|
pump.observe(Some(1), Some(0), None);
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
assert!(!pump.observe(None, None), "没有 pages 树可动 ⇒ 不许白开帧");
|
assert!(
|
||||||
|
!pump.observe(None, None, None),
|
||||||
|
"没有 pages 树可动 ⇒ 不许白开帧"
|
||||||
|
);
|
||||||
assert!(!pump.is_animating());
|
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), "没有旧页可退 ⇒ 按首屏方向");
|
assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,10 +351,10 @@ fn screens_rendered_elsewhere_never_start_a_track() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_second_navigation_mid_flight_takes_over_cleanly() {
|
fn a_second_navigation_mid_flight_takes_over_cleanly() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(1), None);
|
pump.observe(Some(1), Some(0), None);
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
|
|
||||||
pump.observe(Some(2), None); // 往右推进
|
pump.observe(Some(2), Some(1), None); // 往右推进
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
pump.advance(FRAME_MS); // 走 3~5 帧就再点一次
|
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();
|
let restarted = pump.page_frame();
|
||||||
assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)");
|
assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use super::{settle, standard};
|
|||||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||||
|
|
||||||
/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。
|
/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。
|
||||||
|
///
|
||||||
|
/// 抓得住:谁把某一腿改长/改短到不再"进长于出",或把起点缩放从令牌换回字面量。
|
||||||
|
/// 抓不住:这套时长在真机上顺不顺眼(D30 的事)。
|
||||||
#[test]
|
#[test]
|
||||||
fn modal_spec_is_short_and_asymmetric() {
|
fn modal_spec_is_short_and_asymmetric() {
|
||||||
let spec = modal_spec();
|
let spec = modal_spec();
|
||||||
@@ -31,6 +34,7 @@ fn modal_spec_is_short_and_asymmetric() {
|
|||||||
/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒
|
/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒
|
||||||
/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed`
|
/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed`
|
||||||
/// 不返回 `true`(框永远关不掉)。
|
/// 不返回 `true`(框永远关不掉)。
|
||||||
|
/// 抓不住:缩放在**屏幕上**是从中心还是从某个角长出来的(命中与几何都没验,见报告)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
|
fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
@@ -82,6 +86,9 @@ fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。
|
/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `observe_modal(false)` 改成也起退场 ⇒ `is_animating()` 为真,本条红。
|
||||||
|
/// 抓不住:那条硬关路径在真机上多久发生一次(`state/refresh.rs` 的 settle 分支,未统计)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
|
fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
@@ -96,10 +103,13 @@ fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。
|
/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `configure` 里 `tracks.clear()` 那段删掉(轨道留着 ⇒ 仍在占帧)。
|
||||||
|
/// 抓不住:真实用户什么时候会中途改系统开关(那要改系统设置,见报告的未验清单)。
|
||||||
#[test]
|
#[test]
|
||||||
fn switching_to_none_mid_flight_settles_immediately() {
|
fn switching_to_none_mid_flight_settles_immediately() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(1), None);
|
pump.observe(Some(1), Some(0), None);
|
||||||
pump.advance(FRAME_MS);
|
pump.advance(FRAME_MS);
|
||||||
assert!(pump.is_animating());
|
assert!(pump.is_animating());
|
||||||
|
|
||||||
@@ -145,11 +155,14 @@ fn reduced_motion_only_triggers_on_an_explicit_reduced() {
|
|||||||
// ------------------------------------------------------------------ 取证相位
|
// ------------------------------------------------------------------ 取证相位
|
||||||
|
|
||||||
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
|
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `start_now`/`elapsed_ms` 的钉住分支删掉(进度会随时间漂,序列图不再可复现)。
|
||||||
|
/// 抓不住:**出图本身**——这条只证明"相位恒定",图得另外拍(见报告的未验清单)。
|
||||||
#[test]
|
#[test]
|
||||||
fn pinned_phase_freezes_the_clock() {
|
fn pinned_phase_freezes_the_clock() {
|
||||||
let mut pump = Pump::new();
|
let mut pump = Pump::new();
|
||||||
pump.configure(MotionScale::STANDARD, Some(150.0));
|
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 {
|
for _ in 0..10 {
|
||||||
pump.advance(FRAME_MS);
|
pump.advance(FRAME_MS);
|
||||||
@@ -163,3 +176,73 @@ fn pinned_phase_freezes_the_clock() {
|
|||||||
);
|
);
|
||||||
assert!(pump.is_animating(), "钉住的相位不会自然结束(取证专用)");
|
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()` 立刻为假,且不再推进。
|
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
|
||||||
///
|
///
|
||||||
/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。
|
/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。
|
||||||
|
/// 抓不住:整窗重绘次数(那跟随渲染器,见 `FRAME_MS` 的文档)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_finished_animation_stops_asking_for_frames() {
|
fn a_finished_animation_stops_asking_for_frames() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
assert!(pump.observe(Some(1), None));
|
assert!(pump.observe(Some(1), Some(0), None));
|
||||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||||
|
|
||||||
settle(&mut pump);
|
settle(&mut pump);
|
||||||
@@ -31,10 +32,13 @@ fn a_finished_animation_stops_asking_for_frames() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
|
||||||
|
/// 抓不住:**整窗重绘**频率——那跟随渲染器,与这条闸门无关。
|
||||||
#[test]
|
#[test]
|
||||||
fn frames_are_capped_at_sixty_per_second() {
|
fn frames_are_capped_at_sixty_per_second() {
|
||||||
let mut pump = standard();
|
let mut pump = standard();
|
||||||
pump.observe(Some(1), None);
|
pump.observe(Some(1), Some(0), None);
|
||||||
|
|
||||||
let mut advanced = 0;
|
let mut advanced = 0;
|
||||||
for _ in 0..200 {
|
for _ in 0..200 {
|
||||||
@@ -55,10 +59,13 @@ fn frames_are_capped_at_sixty_per_second() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
||||||
|
///
|
||||||
|
/// 抓得住:把 `MAX_STEP_MS` 的夹取删掉(一次 60 s 的 dt 会把轨道当场杀掉 ⇒ 动画一帧走完)。
|
||||||
|
/// 抓不住:真机上「空闲醒来第一帧的 dt 到底多长」(那要真窗口,见报告的未验清单)。
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
||||||
let mut pump = standard();
|
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.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt
|
||||||
assert!(pump.is_animating(), "一次超长 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」=更快,
|
/// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快,
|
||||||
/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。
|
/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。
|
||||||
|
/// 抓不住:**观感上"快一倍"是否合适**(那是 D30 的截图/录屏判断,不是断言)。
|
||||||
#[test]
|
#[test]
|
||||||
fn half_scale_halves_the_duration_not_the_speed() {
|
fn half_scale_halves_the_duration_not_the_speed() {
|
||||||
assert_eq!(MotionScale::HALF.factor(), 0.5);
|
assert_eq!(MotionScale::HALF.factor(), 0.5);
|
||||||
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
|
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
|
||||||
|
|
||||||
let mut full = standard();
|
let mut full = standard();
|
||||||
full.observe(Some(1), None);
|
full.observe(Some(1), Some(0), None);
|
||||||
let full_frames = settle(&mut full);
|
let full_frames = settle(&mut full);
|
||||||
|
|
||||||
let mut half = Pump::new();
|
let mut half = Pump::new();
|
||||||
half.configure(MotionScale::HALF, None);
|
half.configure(MotionScale::HALF, None);
|
||||||
half.observe(Some(1), None);
|
half.observe(Some(1), Some(0), None);
|
||||||
let half_frames = settle(&mut half);
|
let half_frames = settle(&mut half);
|
||||||
|
|
||||||
assert!(half_frames > 0 && full_frames > 0);
|
assert!(half_frames > 0 && full_frames > 0);
|
||||||
|
|||||||
Reference in new issue
Block a user