fix(app-iced): 首屏(Kind::Boot)不再把旧页画成 250ms 幽灵

`pages::render` 把 `previous_view` 整棵画在当前页**下面**(`stack![opaque(ghost),
current]`),只在空隙里露出来。boot 那份快照是 `construct.rs` 的零实例初始模型,
被 `refresh.rs` 在第一次 refresh 时换进去 ⇒ 每次启动都闪一张「这里还没有实例…」+ 空态
图标磁贴(退场腿 250ms,`Kind::Boot` 用的就是它)。

修法:`App::sync_motion` 丢掉旧页快照的判据从「没有轨道了」扩成「没有轨道了 **或**
这一档本来就没有旧页」,后者走 `Kind::has_old_page()`——M256 写好的设计口径,此前全仓
无人调用(死代码)。丢在 `update` 的尾巴、`view` 之前 ⇒ 一帧都画不到;不动
`components/motion/**`(M257 的 scope),也不动 `app/state/refresh.rs`(不在本 scope)。

用例:`motion_sync/tests.rs` 新增三条(首屏每拍都不许留旧页 / Peer 换页仍留着旧页退场 /
NONE 档既不起轨道也不留旧页),删掉守卫即红。`motion_sync.rs` 已 398 行,按 400 行硬线
把测试模块**纯搬移**到 `motion_sync/tests.rs`(233 行逐字节相同,仅去掉模块缩进)。

出图:`DevDocs/artifacts/ghostfix/`(页面区 y<720 残余 = 0px;去掉守卫的同一棵树在同样
三相位残余 6897/5291/4762px)。
This commit is contained in:
AskaEth committed 2026-11-12 19:35:19 +08:00
1 parent a9f691e3ff
commit fb30598604
2 files changed
+343 -236

No files matched your search

+12 -236
View File
@@ -57,7 +57,17 @@ impl App {
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面 // M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。 // 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
if !motion::needs_frames() { //
// M277:**这一档本来就没有旧页时也必须丢**。判据是 `Kind::has_old_page()`(M256 写好的
// 设计口径,此前全仓无人调用 ⇒ 首屏照样画幽灵)。为什么它必须在这里:`pages::render` 把
// `previous_view` 整棵画在当前页**下面**,只在空隙里露出来——而 boot 那份快照是
// `construct.rs` 的零实例初始模型(被 `refresh.rs` 在第一次 refresh 时换进去),
// 于是「每次启动闪一张『还没有实例…』的空态页」约 250~300ms。
// 为什么不在 `refresh.rs` 存之前拦:本 mission 的 scope 不含 `app/state/**`;
// 而这里比"存之前拦"更靠下游一格,任何来源(含将来新增的)都被一并兜住。
// 丢在这里的时机是 `update` 的尾巴、`view` 之前 ⇒ 一帧都画不到。
let keeps_old_page = motion::kind().is_none_or(motion::Kind::has_old_page);
if !motion::needs_frames() || !keeps_old_page {
self.previous_view = None; self.previous_view = None;
} }
} }
@@ -161,238 +171,4 @@ fn nav_rank(route: &Route) -> Option<u8> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests;
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
}
@@ -0,0 +1,331 @@
use super::*;
use crate::app::components::motion::FRAME_MS;
use crate::app::message::Message;
use crate::cli::Options;
use crate::theme::Families;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::motion::MotionScale;
use unlucky_presenter::mock::MockBackend;
use unlucky_presenter::settings_bridge::SettingsBridge;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
/// 不碰磁盘、后端是 mock 的 App(与 `app/tests.rs` 的 `headless` 同构)。
fn headless() -> App {
App::with_presenter(
Options::default(),
FAMILIES,
SchemeFlavor::Light,
Box::new(MockBackend::new()),
SettingsBridge::memory_only(),
)
}
/// 推时间轴到没有轨道(每步 16 ms,上限 20 s)。
fn run_until_idle() {
for _ in 0..1250 {
if !motion::needs_frames() {
return;
}
motion::advance_by(FRAME_MS);
}
}
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。定档走 `configure_for_test`(P2-5,见
/// `a_pinned_scale_survives_boot_with_motion_off`:它把档**钉住**,`on_boot` 的重定档赢不了)。
fn idle_app() -> App {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
app.watch_until = None;
run_until_idle();
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
app
}
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
///
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
#[test]
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
assert_eq!(
keyed(&Route::Detail("survival".to_string())),
(Some(1), Some(0)),
"详情归「实例」"
);
assert_eq!(
keyed(&Route::Accounts),
(None, None),
"账户不归 pages 画、也不在轨上"
);
assert_eq!(
keyed(&Route::Agent),
(None, Some(4)),
"AI 不归 pages 画,但在轨最下面"
);
assert_eq!(
nav_rank(&Route::Content),
Some(1),
"内容库与 Download 共用导航项"
);
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
assert!(
screen_key(&Route::Settings) < screen_key(&Route::Logs),
"屏键里 Settings 在 Logs 之前"
);
assert_eq!(
nav_rank(&Route::Logs),
Some(2),
"轨上 Logs 在 Settings 之前"
);
assert_eq!(nav_rank(&Route::Settings), Some(3));
}
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
///
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
#[test]
fn the_previous_page_model_is_dropped_and_never_accumulates() {
let mut app = idle_app();
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"换页那一拍必须留下**切换前**那一屏"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
// 快速连点:中间只走 4 帧就点下一次
for route in [Route::Content, Route::Settings, Route::Instances] {
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
for _ in 0..4 {
motion::advance_by(FRAME_MS);
}
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
}
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
}
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
///
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
#[test]
fn the_previous_page_is_a_frozen_snapshot() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
let before = app
.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width));
assert!(before.is_some());
app.view_dirty = true;
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
motion::advance_by(FRAME_MS);
assert_eq!(
app.previous_view
.as_ref()
.map(|model| (model.screen, model.window_width)),
before,
"同屏刷新不该换掉旧页快照"
);
}
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条路径**也挡住(`escape` 的闸门)。
/// **抓得住**:删掉任一道闸门(`on_dialog` / `escape`)或把逻辑关闭改成延后,①②③④ 各自红。
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
#[test]
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
use crate::app::dialog::Message as DialogMessage;
use crate::app::update::dialog::{escape, on_dialog};
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
let mut app = idle_app();
let _ = app.state.dialogs.push(
DialogSource::UserAction,
"删除世界",
"不可撤销",
DialogKind::Confirm {
primary: "删除".to_string(),
secondary: "取消".to_string(),
},
);
app.view_dirty = true;
app.refresh_view();
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
let _ = app.update(Message::Frame);
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
run_until_idle();
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
let _ = on_dialog(&mut app, DialogMessage::Secondary);
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
assert!(motion::modal_closing(), "②画面还在退场");
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
let refreshes = app.refreshes;
let _ = on_dialog(&mut app, DialogMessage::Primary);
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
assert!(
app.state.dialogs.current().is_none(),
"③幽灵点击不许复活那个框"
);
assert!(
escape(&mut app),
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
);
run_until_idle();
let _ = app.update(Message::Frame);
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
assert!(!motion::needs_frames(), "退场结束后帧泵必须停");
}
/// **钉住的档必须压过产品启动路径**(复审 r2 的根因守卫):走真 `Message::Boot` ——
/// `on_boot` → `configure_motion` → `boot_env::motion_scale(..)`(本机 CPU 档 ⇒ HALF)→
/// `configure`,全程都不许把测试钉住的 STANDARD 顶掉。
///
/// 为什么不去 `set_var("ULL_MOTION","off")`:那是**进程级写**,并行用例里别的线程正在
/// `getenv`(`boot_env` 每次 Boot 都读),Linux 上这是真竞态。等价做法:把该 env 下
/// `boot_env` 会产出的**取值**(`NONE`)直接喂给 `configure`——见
/// `components::motion::tests::modal::a_pinned_scale_survives_every_later_configure`。
///
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那段 ⇒ 本条红(r2 之前那 7 条红之一
/// 的同一条路)。抓不住:`boot_env` 自己的分支表(env / 后端 tier 的规则,不在本 scope)。
#[test]
fn a_pinned_scale_survives_boot() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::scale(),
MotionScale::STANDARD,
"on_boot 的重定档不许顶掉测试钉住的档(r2 的根因)"
);
motion::reset();
}
/// **首屏(`Kind::Boot`)没有旧页**(M277 / 复审 M257-r1 P1-1 的修):boot 期间 `previous_view`
/// 不许留在那儿等 `pages::render` 去画。
///
/// 幽灵是怎么来的(不必重跑也能读出来):`pages::render` 把 `previous_view` 整棵画在当前页**下面**
/// (`stack![opaque(ghost), current]`),当前页不透明处盖住它,只在空隙里露出文字与图标磁贴;
/// 而 boot 那份快照是 `app/state/construct.rs` 的**零实例初始模型**,被 `refresh.rs` 在第一次
/// refresh 时换进 `previous_view` ⇒ 每次启动闪一张「这里还没有实例…」的空态页直到退场腿走完
/// (`Kind::Boot` 的退场腿 = `PEER_EXIT` 250ms,`transition.rs` 的表)。
///
/// 抓得住:把 `sync_motion` 的判据退回 `if !motion::needs_frames()`(即删掉 `keeps_old_page`
/// 那半句)⇒ boot 那一拍结束时的第一条 `is_none()` 立刻红(实测:`前提/首屏` 那条断言位置报
/// 「首屏(Kind::Boot)不许留旧页 …」),后面「每拍都跑」那条也红。`frame.exiting` 那条断言是
/// 配套的:它证明**那一帧本来会画**,所以这不是「没什么可画」的假绿。
/// 抓不住:`pages::render` 的调用点本身(单测没有 iced 布局树);那一处只认
/// 「`previous_view.is_some()` 且 `frame.exiting`」,本用例把前一半钉成 `None`。
#[test]
fn the_first_screen_never_keeps_a_previous_page_to_draw() {
motion::reset();
motion::configure_for_test(MotionScale::STANDARD, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"前提:首屏起的是 Boot 档(这是「没有旧页」的那一档)"
);
assert!(
motion::frame().exiting,
"前提:Boot 的退场腿还在跑 ⇒ 没有守卫时这一帧就会把旧页画出来"
);
assert!(motion::needs_frames(), "前提:Boot 轨道还在跑");
assert!(
app.previous_view.is_none(),
"首屏(Kind::Boot)不许留旧页:boot 那一拍的 refresh 已经把零实例初始模型换进 \
previous_view,删掉 `keeps_old_page` 那半句即红"
);
// 再钉一次「不是只清了第一拍」:boot 期间又一份快照落地(换页判据会在屏幕上冒出一次
// 「屏或详情变了」)⇒ 守卫必须每一拍都生效,而不是只在 Boot 头一帧清一次。
app.previous_view = Some(pages::Model::new(
pages::Screen::Instances,
app.width,
app.height,
Some(pages::Screen::Instances.nav()),
));
let _ = app.update(Message::Frame);
assert_eq!(
motion::kind(),
Some(motion::Kind::Boot),
"一帧之后仍在 Boot 档(`exiting` 为真的窗口里)"
);
assert!(
app.previous_view.is_none(),
"首屏每一拍都不许留旧页(守卫要每拍都跑,不是只在 Boot 头一帧清一次)"
);
}
/// **正常换页仍然看得见旧页退场**(M277 的反向守卫:不许为首屏把这条一起关掉)。
///
/// Peer 换页(Instances → Settings 这一档)的旧页退场是产品要的;误关掉比幽灵更显眼。
/// 抓得住:把 `keeps_old_page` 写死成假、或把 `sync_motion` 改成无条件清 `previous_view` ⇒ 红。
#[test]
fn a_peer_page_change_still_keeps_the_old_page_for_the_exit_leg() {
let mut app = idle_app();
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
motion::advance_by(FRAME_MS);
assert!(
matches!(motion::kind(), Some(motion::Kind::Peer { .. })),
"前提:同级换页走 Peer 档(有旧页)"
);
assert!(motion::frame().exiting, "前提:退场腿还没走完");
assert_eq!(
app.previous_view.as_ref().map(|model| model.screen),
Some(pages::Screen::Instances),
"Instances → Settings 的旧页必须还留着(删掉旧页退场即红)"
);
}
/// `MotionScale::NONE`:既不起页面轨道,也不留旧页(两条一起钉住,M277 回归项之一)。
///
/// 抓得住:把 `sync_motion` 的 `!needs_frames()` 那半句删掉后若又给 NONE 档插了轨道;
/// 或把守卫写成「只有 Boot 才丢」却让 NONE 档留下快照(`kind()` 为 `None` 时 `is_none_or` 为真,
/// 靠 `!needs_frames()` 兜住——这一条就是那道兜底的守卫)。
#[test]
fn motion_off_leaves_neither_a_page_track_nor_a_previous_page() {
motion::reset();
motion::configure_for_test(MotionScale::NONE, None);
let mut app = headless();
app.window = Some(iced::window::Id::unique());
let _ = app.update(Message::Boot);
assert_eq!(motion::kind(), None, "关掉动效连轨道都不插");
assert!(!motion::needs_frames());
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Settings)));
assert!(app.previous_view.is_none(), "没有过渡就没有旧页可留");
motion::reset();
}