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
@@ -9,20 +9,16 @@
|
||||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||
//! ```
|
||||
//!
|
||||
//! 也就是说**订阅表的求值早于视图**。第一版把「内容标识变了 → 起跑」写在 `pages::render`
|
||||
//! (= `view`)里,于是同一拍订阅表看不见轨道,`frames()` 要等下一条消息(最快鼠标抬起、
|
||||
//! 最慢 1 Hz 心跳)才打开;那一帧的 dt 又是整段空闲时长 ⇒ 轨道被当成「早已到期」清掉,
|
||||
//! 用户看到的是一次 12/8px 的偏移闪跳,比硬切更难看。截图通道因为 `has_screenshot()` 恒真,
|
||||
//! **结构上**不可能暴露这条。
|
||||
//!
|
||||
//! 所以起跑搬到这里:`App::update` 处理完每一条消息后调 [`App::sync_motion`],
|
||||
//! 于是「轨道存在」对**同一拍**的 `subscription()` 可见,帧订阅当拍打开。
|
||||
//! 也就是说**订阅表的求值早于视图**。第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,
|
||||
//! `frames()` 要等下一条消息才打开,而那一帧的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,
|
||||
//! 用户看到的是一次 12/8px 的偏移闪跳(截图通道因为 `has_screenshot()` 恒真,**结构上**不可能
|
||||
//! 暴露这条)。所以起跑搬到这里:`App::update` 处理完每条消息后调 [`App::sync_motion`]。
|
||||
//!
|
||||
//! ## 标识从哪来
|
||||
//!
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源),不从视图模型取:账户页 / AI 独立页不走
|
||||
//! `pages::render`,用路由才能表达「这一屏不归 pages 画」(`None`)——它们之间的切换不起跑,
|
||||
//! 而「从它们回到某一屏」照旧跑换页那一档(复审 r1 结论 4 的硬切顺手收口)。
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」(账户页 /
|
||||
//! AI 独立页不归,它们之间切换不起跑),`nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,
|
||||
//! 不看屏键大小——见 [`nav_rank`])。
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
@@ -30,6 +26,8 @@ use crate::app::components::motion;
|
||||
use crate::pages;
|
||||
|
||||
use super::boot_env::MotionFixture;
|
||||
use crate::app::message::nav_item_of;
|
||||
use crate::shell::NavItem;
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -39,7 +37,9 @@ impl App {
|
||||
// M256:模态退场走完 ⇒ **这一刻**才把保留的框模型丢掉。
|
||||
// 逻辑状态早就在用户点下去的那一拍关了(`state.dialogs` 已清),这里丢的只是"还在缩小的
|
||||
// 那一份画面"。同一拍里置 `view_dirty`,下一帧就没有模态层了。
|
||||
if motion::take_modal_closed() {
|
||||
// M256 r2(P2-2):退场走完只在**队列里确实没有框**时才丢掉画面模型——退场途中开了新框时
|
||||
// `observe_modal(true)` 已把旧退场当场收尾(并留下一次「已关」),照样置空会闪一下。
|
||||
if motion::take_modal_closed() && self.state.dialogs.current().is_none() {
|
||||
self.dialog_model = None;
|
||||
self.view_dirty = true;
|
||||
}
|
||||
@@ -57,14 +57,13 @@ impl App {
|
||||
Route::Detail(id) => Some(id.as_str()),
|
||||
_ => None,
|
||||
};
|
||||
motion::observe(screen, detail);
|
||||
motion::observe(screen, nav_rank(route), detail);
|
||||
// M256:模态的**逻辑**开关(队列里有没有框)。用 presenter 的队列而不是 `dialog_model`:
|
||||
// 退场期间框还画着(`dialog_model` 仍是 `Some`),但逻辑上已经在关的路上了。
|
||||
motion::observe_modal(self.state.dialogs.current().is_some());
|
||||
|
||||
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**。
|
||||
// 这条是硬要求:`previous_view` 是一份完整的页面模型,留着就是「每换一次页多一份」的
|
||||
// 线性增长——长时间常驻的启动器上这是明确的缺陷,不是"小浪费"。
|
||||
// M256:过渡结束(没有轨道了)当拍就**丢掉旧页快照**——`previous_view` 是一份完整的页面
|
||||
// 模型,留着就是「每换一次页多一份」的线性增长(长时间常驻的启动器上不是"小浪费")。
|
||||
if !motion::needs_frames() {
|
||||
self.previous_view = None;
|
||||
}
|
||||
@@ -151,6 +150,24 @@ fn screen_key(route: &Route) -> Option<u64> {
|
||||
.map(|index| index as u64 + 1)
|
||||
}
|
||||
|
||||
/// 这一屏在**导航轨**上的次序(`NavItem::ALL` 的下标)——左右方向的唯一真源。
|
||||
///
|
||||
/// 为什么不能用屏键(`Screen::ALL` 的下标):两处顺序不同。`Screen::ALL` 是
|
||||
/// `Instances / Download / Content / Settings / Logs`,而导航轨是
|
||||
/// `Instances / Download / Logs / Settings / Ai` ⇒ 用屏键会把 **Logs↔Settings 的左右镜像**
|
||||
/// (复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等,方向退回按屏键比大小
|
||||
/// (`Download ↔ Content` 判成同级水平位移)。
|
||||
///
|
||||
/// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画,
|
||||
/// 返回 `None`——它们的切换本来就不起跑页面轨道。
|
||||
fn nav_rank(route: &Route) -> Option<u8> {
|
||||
let item = nav_item_of(route);
|
||||
NavItem::ALL
|
||||
.iter()
|
||||
.position(|candidate| *candidate == item)
|
||||
.map(|index| index as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -190,112 +207,105 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个有窗口、首屏动效已经跑完的 App(`sync_motion` 在没有窗口时直接返回)。
|
||||
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。
|
||||
///
|
||||
/// **定档走 `configure_for_test`(复审 P2-5)**:产品入口 `configure` 会读宿主机「减少动效」
|
||||
/// 开关,用例在关了系统动画的机器上就会红。真探针的接线由 `motion::tests::modal` 里那条用例守。
|
||||
fn idle_app() -> App {
|
||||
motion::reset();
|
||||
motion::configure(MotionScale::STANDARD, None);
|
||||
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
// 再走一条消息:`sync_motion` 在这一拍把启动期那次换页的 `previous_view` 烧掉
|
||||
// (首屏从「构造时的模型」换到真路由,本身就是一次内容标识变化)。
|
||||
// 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
app
|
||||
}
|
||||
|
||||
/// 路由 → 屏键:五个一级页各自唯一,`Detail` 归「实例」,账户/AI 不归 pages。
|
||||
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
|
||||
///
|
||||
/// 抓得住:①`Screen::of` 与 `Screen::ALL` 的对应被改坏;②把 `nav_rank` 改成返回屏键(或退回
|
||||
/// 比较 `Screen::ALL` 下标)——下面那条「轨序里 Settings 在 Logs 之后」会红,因为屏键顺序恰好
|
||||
/// 相反;③谁调了 `Screen::ALL` 的声明顺序(本用例把这对**相反的不等关系**两侧都钉住)。
|
||||
/// 抓不住:导航轨的视觉几何(`shell/nav.rs` 的间距/药丸位置归它自己的单测)。
|
||||
#[test]
|
||||
fn only_the_five_screens_get_a_key() {
|
||||
let keys: Vec<Option<u64>> = [
|
||||
Route::Instances,
|
||||
Route::Download,
|
||||
Route::Content,
|
||||
Route::Settings,
|
||||
Route::Logs,
|
||||
Route::Detail("survival".to_string()),
|
||||
Route::Accounts,
|
||||
Route::Agent,
|
||||
]
|
||||
.iter()
|
||||
.map(screen_key)
|
||||
.collect();
|
||||
|
||||
assert_eq!(&keys[..5], &[Some(1), Some(2), Some(3), Some(4), Some(5)]);
|
||||
fn the_rail_order_and_the_screen_table_disagree_on_purpose() {
|
||||
let keyed = |route: &Route| (screen_key(route), nav_rank(route));
|
||||
assert_eq!(
|
||||
keys[5],
|
||||
Some(1),
|
||||
"详情页归「实例」那一屏(与 Screen::of 同源)"
|
||||
keyed(&Route::Detail("survival".to_string())),
|
||||
(Some(1), Some(0)),
|
||||
"详情归「实例」"
|
||||
);
|
||||
assert_eq!(keys[6], None, "账户页不归 pages 画");
|
||||
assert_eq!(keys[7], None, "AI 独立页不归 pages 画");
|
||||
assert_eq!(
|
||||
keyed(&Route::Accounts),
|
||||
(None, None),
|
||||
"账户不归 pages 画、也不在轨上"
|
||||
);
|
||||
assert_eq!(
|
||||
keyed(&Route::Agent),
|
||||
(None, Some(4)),
|
||||
"AI 不归 pages 画,但在轨最下面"
|
||||
);
|
||||
assert_eq!(
|
||||
nav_rank(&Route::Content),
|
||||
Some(1),
|
||||
"内容库与 Download 共用导航项"
|
||||
);
|
||||
|
||||
// 两条**相反**的不等关系:这就是 P2-1 修的镜像
|
||||
assert!(
|
||||
screen_key(&Route::Settings) < screen_key(&Route::Logs),
|
||||
"屏键里 Settings 在 Logs 之前"
|
||||
);
|
||||
assert_eq!(
|
||||
nav_rank(&Route::Logs),
|
||||
Some(2),
|
||||
"轨上 Logs 在 Settings 之前"
|
||||
);
|
||||
assert_eq!(nav_rank(&Route::Settings), Some(3));
|
||||
}
|
||||
|
||||
/// **旧页模型用完即焚**(M256 门槛 B):过渡结束的那一拍 `previous_view` 必须是 `None`。
|
||||
/// **旧页模型用完即焚,且快速连点也不累积**(门槛 B)。
|
||||
///
|
||||
/// 抓得住:把 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` 删掉
|
||||
/// ——那样每换一次页就永久多留一份完整模型,长时间跑下去是线性增长。
|
||||
/// 抓不住:模型有多大(这条只看"有没有留")。
|
||||
/// 抓得住:删掉 `sync_motion` 末尾的 `if !needs_frames() { previous_view = None }` ⇒ 两条
|
||||
/// `is_none()` 断言红。**抓不住、也不假装抓**「中途不会攒到 2 份」:字段是
|
||||
/// `Option<pages::Model>`,结构上装不下第二份(`refresh.rs` 用 `mem::replace` **移动**);
|
||||
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
|
||||
#[test]
|
||||
fn the_previous_page_model_is_dropped_when_the_transition_ends() {
|
||||
fn the_previous_page_model_is_dropped_and_never_accumulates() {
|
||||
let mut app = idle_app();
|
||||
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
assert!(
|
||||
app.previous_view.is_some(),
|
||||
"换页那一拍必须留下旧页(退场要画它)"
|
||||
);
|
||||
assert_eq!(
|
||||
app.previous_view.as_ref().map(|model| model.screen),
|
||||
Some(pages::Screen::Instances),
|
||||
"旧页必须是**切换前**那一屏"
|
||||
"换页那一拍必须留下**切换前**那一屏"
|
||||
);
|
||||
|
||||
run_until_idle();
|
||||
// 下一拍消息(真机上就是最后一帧)收尾。
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(
|
||||
app.previous_view.is_none(),
|
||||
"过渡结束必须当拍丢掉旧页模型(不许随换页次数线性增长)"
|
||||
);
|
||||
}
|
||||
assert!(app.previous_view.is_none(), "过渡结束必须当拍丢掉旧页模型");
|
||||
|
||||
/// **连续多次换页也只保有 1 份旧模型**(门槛 B 的另一半)。
|
||||
#[test]
|
||||
fn repeated_page_changes_never_accumulate_models() {
|
||||
let mut app = idle_app();
|
||||
let routes = [
|
||||
Route::Download,
|
||||
Route::Content,
|
||||
Route::Settings,
|
||||
Route::Instances,
|
||||
];
|
||||
for route in routes {
|
||||
// 快速连点:中间只走 4 帧就点下一次
|
||||
for route in [Route::Content, Route::Settings, Route::Instances] {
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(route)));
|
||||
// 中间只走 4 帧再点下一次(模拟快速连点导航轨)
|
||||
for _ in 0..4 {
|
||||
motion::advance_by(FRAME_MS);
|
||||
}
|
||||
let count = usize::from(app.previous_view.is_some());
|
||||
assert!(count <= 1, "同一时刻最多只有 1 份旧页模型,实测 {count}");
|
||||
assert!(app.previous_view.is_some(), "过渡中途必须留着旧页");
|
||||
}
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Logs)));
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(
|
||||
app.previous_view.is_none(),
|
||||
"连续 5 次换页之后仍应是 None(不是攒了 5 份)"
|
||||
);
|
||||
assert!(app.previous_view.is_none(), "连点 4 次之后仍应是 None");
|
||||
}
|
||||
|
||||
/// 旧页是**冻结快照**(门槛 C):过渡期间的刷新不许把它换成新内容。
|
||||
///
|
||||
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成"每次刷新都换"——那样旧页会跟着
|
||||
/// presenter 变,画面上就是两个页面同时动内容。
|
||||
/// 抓得住:把 `refresh.rs` 的「只在屏/详情变了才换」改成每次刷新都换 ⇒ 旧页跟着 presenter 变,
|
||||
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
|
||||
#[test]
|
||||
fn the_previous_page_is_a_frozen_snapshot() {
|
||||
let mut app = idle_app();
|
||||
@@ -306,9 +316,8 @@ mod tests {
|
||||
.map(|model| (model.screen, model.window_width));
|
||||
assert!(before.is_some());
|
||||
|
||||
// 过渡期间来一次「同屏刷新」(主题/下载进度这一类的形状:不换屏)
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
app.refresh_view(); // 同屏刷新(主题 / 下载进度那一类的形状)
|
||||
motion::advance_by(FRAME_MS);
|
||||
|
||||
assert_eq!(
|
||||
@@ -320,20 +329,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **退场窗口内那个框已经关掉,且不可再交互**(tower 的硬要求)。
|
||||
///
|
||||
/// 四件事分别断言:①逻辑状态当拍就空;②画面还在退场(`dialog_model` 仍 `Some` ⇒ 不是硬跳);
|
||||
/// ③这 150ms 内再点一次**什么都不发生**(`on_dialog` 的闸门);④**Esc 那条独立路径**也挡住。
|
||||
///
|
||||
/// **抓得住**:把 `on_dialog` 顶部的 `if motion::modal_closing()` 删掉(第二次点击会走进
|
||||
/// `close(app)`,`refreshes` 涨一次 ⇒ 红);或让逻辑关闭延后(①红);或让退场直接清模型(②红)。
|
||||
/// **抓不住**:几何级的命中测试——「点在那个坐标上会不会命中幽灵框」。本仓的单元测试没有
|
||||
/// iced 的布局树,`zoom.rs` 里的缩放也**不改**命中矩形(记在它自己的"已知局限"里)。
|
||||
/// 这一条由闸门保证:**无论命中在哪,退场期间的弹窗消息一律不生效**。
|
||||
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
|
||||
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
|
||||
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条独立路径**也挡住(`escape` 的闸门)。
|
||||
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
|
||||
#[test]
|
||||
fn the_exiting_dialog_is_already_closed_and_swallows_clicks() {
|
||||
use crate::app::dialog::Message as DialogMessage;
|
||||
use crate::app::update::dialog::on_dialog;
|
||||
use crate::app::update::dialog::{escape, on_dialog};
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
|
||||
let mut app = idle_app();
|
||||
@@ -348,46 +351,29 @@ mod tests {
|
||||
);
|
||||
app.view_dirty = true;
|
||||
app.refresh_view();
|
||||
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机的顺序一致)
|
||||
// 一条消息让 `sync_motion` 看见「队列里有框」⇒ 起跑进场(与真机顺序一致)
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(motion::needs_frames(), "开框必须在同一拍起跑进场");
|
||||
run_until_idle(); // 进场跑完(`Open` 稳态)
|
||||
run_until_idle();
|
||||
assert!(app.dialog_model.is_some(), "前提:框已经画出来了");
|
||||
|
||||
// 用户点「取消」:逻辑当拍关闭,画面开始退场
|
||||
let _ = on_dialog(&mut app, DialogMessage::Secondary);
|
||||
assert!(
|
||||
app.state.dialogs.current().is_none(),
|
||||
"退场窗口内逻辑上必须**已经**关掉(任何状态读取者都该看到没有框)"
|
||||
);
|
||||
assert!(motion::modal_closing(), "画面还在退场");
|
||||
assert!(
|
||||
app.dialog_model.is_some(),
|
||||
"退场窗口内仍画得到旧框(否则就是一次硬跳)"
|
||||
);
|
||||
assert!(motion::needs_frames(), "退场要帧");
|
||||
assert!(app.state.dialogs.current().is_none(), "①逻辑上已经关掉");
|
||||
assert!(motion::modal_closing(), "②画面还在退场");
|
||||
assert!(app.dialog_model.is_some(), "②仍画得到旧框(不是硬跳)");
|
||||
|
||||
// 150ms 内再点一次原来的主按钮位置:必须被闸门吞掉
|
||||
let refreshes = app.refreshes;
|
||||
let _ = on_dialog(&mut app, DialogMessage::Primary);
|
||||
assert_eq!(
|
||||
app.refreshes, refreshes,
|
||||
"退场窗口内的点击必须被吞掉(连一次重画都不该发生)"
|
||||
);
|
||||
assert_eq!(app.refreshes, refreshes, "③退场期点击必须被吞掉");
|
||||
assert!(
|
||||
app.state.dialogs.current().is_none(),
|
||||
"幽灵点击不许复活那个框"
|
||||
"③幽灵点击不许复活那个框"
|
||||
);
|
||||
assert!(app.dialog_model.is_some(), "也不许把它重新打开");
|
||||
// **键盘入口单列一条**:`Esc` 不走 `on_dialog`(它是 `Message::Agent(Key)` 那条路),
|
||||
// 必须在 `escape` 里单独挡住,否则退场那 150ms 里 Esc 会落到 AI 栏去收起面板。
|
||||
assert!(
|
||||
crate::app::update::dialog::escape(&mut app),
|
||||
"退场窗口内的 Esc 也必须被吃掉(不许落到 AI 栏)"
|
||||
escape(&mut app),
|
||||
"④退场期 Esc 也必须被吃掉(不许落到 AI 栏)"
|
||||
);
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
|
||||
// 退场走完:下一拍的 `sync_motion` 把它丢掉,帧泵停
|
||||
run_until_idle();
|
||||
let _ = app.update(Message::Frame);
|
||||
assert!(app.dialog_model.is_none(), "退场结束后不许留残影");
|
||||
|
||||
Reference in new issue
Block a user