fix(app-iced): 删掉空操作串行门 + 钉住档位贯穿产品启动路径 + 退场起点接当前进度
复审 r2 的三条: 1) 删掉 motion::serialize() 那把串行门:PUMP/LAST_FRAME 都是 thread_local,libtest 每用例 一线程 ⇒ 它改变不了任何结果(ULL_MOTION=off 下握门照样红)。留一个不起作用的同步原语 比没有更坏。subscription.rs 与 motion_sync.rs 的 idle_app 恢复原签名。 2) 真实根因是「喂给 configure 的值」:idle_app 先钉 STANDARD,随后 on_boot → configure_motion(app/update.rs:276)→ boot_env::motion_scale(..) 再定一次档,把钉住覆盖掉。 改成 BYPASS_PROBE(bool) → FORCED_SCALE(Cell<Option<MotionScale>>):configure 里钉住优先, 于是三条定档路径(configure_for_test / on_boot / boot_env 的 env·后端 tier)都穿不过去。 两条用例钉住它(纯合成 + 真 Message::Boot),删掉「钉住优先」两处都红。 3) P2-2:Modal::Closing 记住起手进度 from,progress = from*(1-eased) —— 进场途中被关掉时 从当前进度接着缩,不再先跳到 1.0(反向硬跳)。新用例 closing_during_the_entrance_does_not_jump_to_fully_open,把 from 改回 1.0 即红。
This commit is contained in:
1 parent
c21c6df51b
commit
787cb16189
4 files changed
+122
-44
No files matched your search
@@ -110,7 +110,7 @@ mod tests {
|
||||
//! 第一版这里是「先 `motion::enter(...)` 再问 `needs_frames()`」——而 iced 的真实顺序是
|
||||
//! 「`update()`(订阅表在这里求值)→ `view()`」,于是守卫全绿、生产路径上却根本播不出来。
|
||||
//! 现在全部改走**真消息**,并断言 `watch_until` 仍是 `None`(排除「观察窗顺手开了帧」的假绿)。
|
||||
//! 时间轴用 `motion::advance_by` 摆;动效全局态由 `motion::serialize()` 的串行门独占。
|
||||
//! 时间轴用 `motion::advance_by` 摆(不等真实时钟);动效全局态是 thread-local,天然隔离。
|
||||
|
||||
use super::*;
|
||||
use crate::app::components::motion::FRAME_MS;
|
||||
@@ -147,9 +147,7 @@ mod tests {
|
||||
///
|
||||
/// `Boot` 自己就会起跑一次首屏动效(`on_boot` → `sync_motion`),所以必须先把它推完,
|
||||
/// 否则后面的 `assert!(!needs_frames())` 测的是「动画还在跑」而不是「空闲」。
|
||||
fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) {
|
||||
// **串行门**(复审 P2-5 第二半):动效全局态必须由本用例独占,别并行踩。
|
||||
let gate = motion::serialize();
|
||||
fn idle_app() -> App {
|
||||
motion::reset();
|
||||
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
@@ -159,7 +157,7 @@ mod tests {
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
(app, gate)
|
||||
app
|
||||
}
|
||||
|
||||
/// 把时间轴推到全部轨道结束(每步 16 ms,上限 20 s)。
|
||||
@@ -178,7 +176,7 @@ mod tests {
|
||||
/// `assert!(app.needs_frames())` 红;把 `Pump::advance` 的「到期即删除」删掉 ⇒ 最后一步红。
|
||||
#[test]
|
||||
fn a_state_change_opens_the_frame_subscription_in_the_same_tick() {
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
assert!(!app.needs_frames(), "前提:空闲交互态本来就不订阅帧");
|
||||
|
||||
// 用户点了一次导航轨:**一条**消息,之后不再补任何消息
|
||||
@@ -206,7 +204,7 @@ mod tests {
|
||||
/// 列表 ↔ 详情同样走「一条消息 ⇒ 当拍有帧」,且 `Detail` 槽真的是那条。
|
||||
#[test]
|
||||
fn opening_a_detail_also_opens_the_frames_in_the_same_tick() {
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
// 出厂快照会自动选中第一条 ⇒ 先回列表,让「点进详情」真的是一次详情变化
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Instances)));
|
||||
run_until_idle();
|
||||
@@ -223,7 +221,7 @@ mod tests {
|
||||
/// 账户页 / AI 独立页**不归 pages 画**:切过去不起跑(否则白跑 300 ms 帧);切回来照旧跑。
|
||||
#[test]
|
||||
fn pages_that_render_elsewhere_do_not_start_a_track() {
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Accounts)));
|
||||
assert!(
|
||||
!motion::needs_frames(),
|
||||
@@ -240,7 +238,6 @@ mod tests {
|
||||
/// 首屏那一条也因此要等窗口到了才算「第一眼」。
|
||||
#[test]
|
||||
fn without_a_window_nothing_animates() {
|
||||
let _gate = motion::serialize();
|
||||
motion::reset();
|
||||
motion::configure_for_test(MotionScale::STANDARD, None);
|
||||
let mut app = headless();
|
||||
@@ -260,7 +257,6 @@ mod tests {
|
||||
/// 变异证明:把 `Pump::play_page` 的 `is_off()` 分支删掉 ⇒ 帧采样次数非 0,本用例红。
|
||||
#[test]
|
||||
fn no_scale_requests_no_frames_at_all() {
|
||||
let _gate = motion::serialize();
|
||||
motion::reset();
|
||||
let mut app = headless();
|
||||
app.window = Some(iced::window::Id::unique());
|
||||
|
||||
@@ -2,23 +2,16 @@
|
||||
//!
|
||||
//! ## 为什么这条不能在 `view` 里
|
||||
//!
|
||||
//! iced 0.14 的处理顺序是:
|
||||
//!
|
||||
//! ```text
|
||||
//! 消息 ──► update() ← program.subscription() 在这里求值(iced_winit src/lib.rs:1338)
|
||||
//! ──► build_user_interfaces() ← 这时才调 program.view(src/lib.rs:1233)
|
||||
//! ```
|
||||
//!
|
||||
//! 也就是说**订阅表的求值早于视图**。第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,
|
||||
//! `frames()` 要等下一条消息才打开,而那一帧的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,
|
||||
//! 用户看到的是一次 12/8px 的偏移闪跳(截图通道因为 `has_screenshot()` 恒真,**结构上**不可能
|
||||
//! 暴露这条)。所以起跑搬到这里:`App::update` 处理完每条消息后调 [`App::sync_motion`]。
|
||||
//! iced 0.14 的顺序:`update()`(**订阅表在这里求值**,iced_winit `src/lib.rs:1338`)→
|
||||
//! `build_user_interfaces()`(这时才调 `view`,`src/lib.rs:1233`)⇒ **订阅表的求值早于视图**。
|
||||
//! 第一版把起跑写在 `view` 里,订阅表当拍看不见轨道,`frames()` 要等下一条消息才打开,而那一帧
|
||||
//! 的 dt 是整段空闲 ⇒ 轨道被当成早已到期清掉,用户看到的是一次 12/8px 的偏移闪跳(截图通道
|
||||
//! 因为 `has_screenshot()` 恒真,**结构上**不可能暴露这条)。所以起跑搬到这里。
|
||||
//!
|
||||
//! ## 标识从哪来
|
||||
//!
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」(账户页 /
|
||||
//! AI 独立页不归,它们之间切换不起跑),`nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,
|
||||
//! 不看屏键大小——见 [`nav_rank`])。
|
||||
//! 从**路由**取(`AppState::route()` 是唯一真源):`screen_key` 说「归不归 pages 画」,
|
||||
//! `nav_rank` 说「在**导航轨**上的左右次序」(方向只看它,不看屏键大小——见 [`nav_rank`])。
|
||||
|
||||
use unlucky_presenter::router::Route;
|
||||
|
||||
@@ -153,10 +146,9 @@ fn screen_key(route: &Route) -> Option<u64> {
|
||||
/// 这一屏在**导航轨**上的次序(`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` 判成同级水平位移)。
|
||||
/// `… Content / Settings / Logs`,而导航轨是 `… Logs / Settings / Ai` ⇒ 用屏键会把
|
||||
/// **Logs↔Settings 的左右镜像**(复审 P2-1)。`Content` 与 `Download` 共用导航项,`rank` 相等,
|
||||
/// 方向退回按屏键比大小(`Download ↔ Content` 判成同级水平位移)。
|
||||
///
|
||||
/// 账户头像(`NavItem::Account`)与 AI(`NavItem::Ai`)不在这个集合里 / 不归 pages 画,
|
||||
/// 返回 `None`——它们的切换本来就不起跑页面轨道。
|
||||
@@ -207,13 +199,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 有窗口、首屏动效已跑完、旧页模型已清空的 App。
|
||||
///
|
||||
/// **定档走 `configure_for_test`(复审 P2-5)**:产品入口 `configure` 会读宿主机「减少动效」
|
||||
/// 开关,用例在关了系统动画的机器上就会红。真探针的接线由 `motion::tests::modal` 里那条用例守。
|
||||
fn idle_app() -> (App, std::sync::MutexGuard<'static, ()>) {
|
||||
// **串行门**(复审 P2-5 第二半):动效全局态由本用例独占,别与并行用例互踩。
|
||||
let gate = motion::serialize();
|
||||
/// 有窗口、首屏动效已跑完、旧页模型已清空的 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();
|
||||
@@ -221,10 +209,9 @@ mod tests {
|
||||
let _ = app.update(Message::Boot);
|
||||
app.watch_until = None;
|
||||
run_until_idle();
|
||||
// 再走一条消息:`sync_motion` 在那一拍把启动期换页留下的 `previous_view` 烧掉。
|
||||
let _ = app.update(Message::Frame);
|
||||
let _ = app.update(Message::Frame); // 让 `sync_motion` 把启动期那次换页的旧页烧掉
|
||||
assert!(!motion::needs_frames(), "前提:首屏动效已经跑完");
|
||||
(app, gate)
|
||||
app
|
||||
}
|
||||
|
||||
/// 屏键 / 轨序两张表:**方向只听轨序**(复审 P2-1 的守卫)。
|
||||
@@ -278,7 +265,7 @@ mod tests {
|
||||
/// 旧版 `usize::from(is_some()) <= 1` 恒真。真要证明释放得靠析构计数插桩——**没做**。
|
||||
#[test]
|
||||
fn the_previous_page_model_is_dropped_and_never_accumulates() {
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
assert!(app.previous_view.is_none(), "前提:静息态没有旧页");
|
||||
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
@@ -310,7 +297,7 @@ mod tests {
|
||||
/// 画面上两个页面同时在动内容。抓不住:`States`(外观/组状态/设置快照)不随模型冻结(偏离 #4)。
|
||||
#[test]
|
||||
fn the_previous_page_is_a_frozen_snapshot() {
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
let _ = app.update(Message::Page(pages::Message::Navigate(Route::Download)));
|
||||
let before = app
|
||||
.previous_view
|
||||
@@ -333,15 +320,15 @@ mod tests {
|
||||
|
||||
/// **退场窗口内那个框已经关掉、且不可再交互**(tower 的硬要求)。四件事:①逻辑状态当拍就空;
|
||||
/// ②画面还在退场(`dialog_model` 仍 `Some`);③150ms 内再点一次**什么都不发生**(`refreshes`
|
||||
/// 不涨 = `on_dialog` 的闸门);④**Esc 那条独立路径**也挡住(`escape` 的闸门)。
|
||||
/// **抓不住**:几何级命中(单测没有 iced 布局树,`zoom.rs` 也不改命中矩形)——那一层由闸门保证。
|
||||
/// 不涨 = `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::{escape, on_dialog};
|
||||
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
|
||||
|
||||
let (mut app, _gate) = idle_app();
|
||||
let mut app = idle_app();
|
||||
let _ = app.state.dialogs.push(
|
||||
DialogSource::UserAction,
|
||||
"删除世界",
|
||||
@@ -381,4 +368,30 @@ mod tests {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,9 @@ impl Pump {
|
||||
Modal::Opening { start_ms } if now - start_ms >= self.leg_ms(spec.enter.duration) => {
|
||||
self.modal = Modal::Open
|
||||
}
|
||||
Modal::Closing { start_ms } if now - start_ms >= self.leg_ms(spec.exit.duration) => {
|
||||
Modal::Closing { start_ms, .. }
|
||||
if now - start_ms >= self.leg_ms(spec.exit.duration) =>
|
||||
{
|
||||
self.modal = Modal::Hidden;
|
||||
self.close_pending = true;
|
||||
}
|
||||
|
||||
@@ -246,3 +246,70 @@ fn configure_goes_through_the_real_os_probe() {
|
||||
);
|
||||
reset();
|
||||
}
|
||||
|
||||
/// **进场途中被关掉**:进度必须从**当前值**接着缩,不许先跳到 1.0 再退(复审 r2 的 P2-2)。
|
||||
///
|
||||
/// 抓得住:把 `begin_modal_close` 的起点改回恒 `1.0`(旧实现)⇒ 下面那条
|
||||
/// `(关掉后首帧)<=(关掉前一帧)` 立刻红——那就是"反向硬跳"。
|
||||
/// 抓不住:那一帧在真实屏幕上有多刺眼(D30 的观感,不是断言)。
|
||||
#[test]
|
||||
fn closing_during_the_entrance_does_not_jump_to_fully_open() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe_modal(true), "起跑进场");
|
||||
for _ in 0..3 {
|
||||
pump.advance(FRAME_MS); // 走到进场的 1/4 左右
|
||||
}
|
||||
let before = pump.modal_frame().progress;
|
||||
assert!(
|
||||
before > 0.0 && before < 1.0,
|
||||
"前提:进场走到半路,实测 {before}"
|
||||
);
|
||||
|
||||
assert!(!pump.begin_modal_close(), "有动效 ⇒ 起跑退场");
|
||||
let first = pump.modal_frame().progress;
|
||||
assert!(
|
||||
first <= before + 1.0e-6,
|
||||
"关掉后的首帧进度不许**变大**({before} → {first} 就是反向硬跳)"
|
||||
);
|
||||
assert!(
|
||||
(first - before).abs() < 0.05,
|
||||
"起点必须接在当前进度上({before} → {first})"
|
||||
);
|
||||
|
||||
// 之后单调缩到 0,且必须真的走完(交回一次「已关」)
|
||||
let mut previous = first;
|
||||
while pump.is_animating() {
|
||||
pump.advance(FRAME_MS);
|
||||
let now = pump.modal_frame().progress;
|
||||
assert!(
|
||||
now <= previous + 1.0e-6,
|
||||
"退场必须单调缩小:{previous} → {now}"
|
||||
);
|
||||
previous = now;
|
||||
}
|
||||
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
|
||||
assert!(pump.take_modal_closed());
|
||||
}
|
||||
|
||||
/// **钉住的档压过任何来源的取值**(复审 r2 的根因机制)。
|
||||
///
|
||||
/// `App::on_boot` 会再调一次 `configure`(`app/update.rs:276`),值来自
|
||||
/// `boot_env::motion_scale(...)`(本机默认 HALF、`ULL_MOTION=off` 时 NONE)。本用例把那次调用
|
||||
/// 的**取值**直接喂进来:钉住 STANDARD 之后,`configure(HALF)` / `configure(NONE)` 都改不动它。
|
||||
///
|
||||
/// 抓得住:删掉 `configure` 里「`FORCED_SCALE` 优先」那一段 ⇒ 两行断言全红(= r2 之前的真红)。
|
||||
/// 抓不住:`boot_env` 自己那张分支表(env / 后端 tier 的取值规则,不在本 mission 的 scope)。
|
||||
#[test]
|
||||
fn a_pinned_scale_survives_every_later_configure() {
|
||||
reset();
|
||||
configure_for_test(MotionScale::STANDARD, None);
|
||||
configure(MotionScale::HALF, None); // = 本机 ICED_BACKEND 未设时 boot_env 的默认档
|
||||
assert_eq!(scale(), MotionScale::STANDARD);
|
||||
configure(MotionScale::NONE, None); // = ULL_MOTION=off 时 boot_env 的取值
|
||||
assert_eq!(
|
||||
scale(),
|
||||
MotionScale::STANDARD,
|
||||
"钉住的档必须压过 on_boot 的取值"
|
||||
);
|
||||
reset();
|
||||
}
|
||||
Reference in new issue
Block a user