Files
Unlucky-Launcher/crates/app-iced/src/components/motion/tests/modal.rs
T
AskaEth 787cb16189 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 即红。
2026-11-12 10:50:03 +08:00

316 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 模态 / 系统偏好 / 有界泵的降级守卫(M256)。从 `tests/mod.rs` 拆出来守 400 行硬线。
use super::super::pump::FRAME_MS;
use super::super::transition::{modal_spec, page_spec, Kind, ModalFrame, PageFrame};
use super::super::*;
use super::{settle, standard};
use unlucky_design::motion::{DurationToken, MotionScale};
/// 模态表:进 200 / 出 150,且起点缩放来自 design 令牌(0.96)。
///
/// 抓得住:谁把某一腿改长/改短到不再"进长于出",或把起点缩放从令牌换回字面量。
/// 抓不住:这套时长在真机上顺不顺眼(D30 的事)。
#[test]
fn modal_spec_is_short_and_asymmetric() {
let spec = modal_spec();
assert_eq!(spec.enter.duration, DurationToken::Short4); // 200
assert_eq!(spec.exit.duration, DurationToken::Short3); // 150
assert!(spec.enter.duration.ms() > spec.exit.duration.ms());
assert!((modal_scale(0.0) - unlucky_design::motion::DIALOG_ENTER_SCALE).abs() < 1.0e-6);
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6);
// 单调(不弹跳:M3E 的对话框不许回弹)
let mut previous = modal_scale(0.0);
for step in 1..=20 {
let value = modal_scale(step as f32 / 20.0);
assert!(value >= previous, "缩放倒退了:{previous} → {value}");
previous = value;
}
}
// ------------------------------------------------------------------ 模态
/// 模态:进场 12~13 帧(200ms)、展开后**不再要帧**、退场反向且更短、走完交回一次「已关」。
///
/// 抓得住:①进场时长被改成 >300ms(帧数跑出去);②`Open` 还占着轨道(`is_animating` 为真 ⇒
/// 对话框开着就一直在烧帧);③退场方向反了(`progress` 变成递增);④`take_modal_closed`
/// 不返回 `true`(框永远关不掉)。
/// 抓不住:缩放在**屏幕上**是从中心还是从某个角长出来的(命中与几何都没验,见报告)。
#[test]
fn a_modal_opens_stops_burning_frames_and_closes_in_reverse() {
let mut pump = standard();
assert!(pump.observe_modal(true), "开框要起跑进场");
assert!(pump.modal_frame().visible);
assert!(pump.modal_frame().progress < 0.5, "起手时是收起状态");
let enter_frames = settle(&mut pump) as usize;
assert!(
(10..=14).contains(&enter_frames),
"进场 200ms 应约 12 帧,实测 {enter_frames}"
);
assert_eq!(
pump.modal_frame(),
ModalFrame {
visible: true,
progress: 1.0
}
);
assert!(
!pump.is_animating(),
"**展开后不许占帧**(对话框可能开几分钟,那不是动画)"
);
assert!(!pump.observe_modal(true), "已经开着 ⇒ 不重新起跑");
// 退场:反向(1 → 0),且比进场短
assert!(!pump.begin_modal_close(), "有动效时不许当场关");
assert!(pump.modal_closing());
let mut trace = vec![pump.modal_frame().progress];
while pump.is_animating() {
pump.advance(FRAME_MS);
trace.push(pump.modal_frame().progress);
}
let exit_frames = trace.len() - 1;
assert!(
exit_frames < enter_frames,
"退场({exit_frames} 帧)必须短于进场({enter_frames} 帧)"
);
assert!(
trace.windows(2).all(|pair| pair[0] >= pair[1]),
"退场进度必须单调下降:{trace:?}"
);
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN, "走完即收起");
assert!(pump.take_modal_closed(), "必须交回一次「已关」");
assert!(
!pump.take_modal_closed(),
"只交回一次(重复关会把下一个框也关掉)"
);
}
/// 弹窗被别处**硬关**(不是走 `begin_modal_close`)时不玩退场:直接收起、不残留「待关」。
///
/// 抓得住:把 `observe_modal(false)` 改成也起退场 ⇒ `is_animating()` 为真,本条红。
/// 抓不住:那条硬关路径在真机上多久发生一次(`state/refresh.rs` 的 settle 分支,未统计)。
#[test]
fn a_dialog_closed_elsewhere_does_not_leave_a_pending_close() {
let mut pump = standard();
pump.observe_modal(true);
settle(&mut pump);
assert!(pump.modal_frame().visible);
assert!(!pump.observe_modal(false));
assert!(!pump.is_animating());
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
assert!(!pump.take_modal_closed(), "没有退场就没有「待关」");
}
/// 运行期把关掉动效(`configure(NONE)`)要立刻落终态,而不是把动画「缩到 0 时长」。
///
/// 抓得住:把 `configure` 里 `tracks.clear()` 那段删掉(轨道留着 ⇒ 仍在占帧)。
/// 抓不住:真实用户什么时候会中途改系统开关(那要改系统设置,见报告的未验清单)。
#[test]
fn switching_to_none_mid_flight_settles_immediately() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
pump.advance(FRAME_MS);
assert!(pump.is_animating());
pump.configure(MotionScale::NONE, None);
assert!(!pump.is_animating(), "关掉动效必须当场清空轨道");
assert_eq!(pump.page_frame(), PageFrame::STILL);
}
// ------------------------------------------------------------------ 系统偏好合成
/// 系统「减少动效」三态与档位的合成(塔的硬约束 C):**`Unknown` 什么都不改**。
///
/// 抓得住:把 `Unknown` 也降级成 `NONE`("探针失败就静默关动效"),或把 `Reduced` 漏了。
/// 抓不住:真机注册表/系统设置里的值(那要改系统设置,见报告"没验的部分")。
#[test]
fn reduced_motion_only_triggers_on_an_explicit_reduced() {
use crate::platform::motion::ReduceMotion;
for scale in [MotionScale::STANDARD, MotionScale::HALF, MotionScale::NONE] {
assert_eq!(
effective_scale(scale, ReduceMotion::Reduced, false),
MotionScale::NONE,
"系统明确要求减少动效 ⇒ 归零"
);
assert_eq!(
effective_scale(scale, ReduceMotion::AnimationsOn, false),
scale,
"系统里动效开着 ⇒ 用原档"
);
assert_eq!(
effective_scale(scale, ReduceMotion::Unknown, false),
scale,
"**读不到 ≠ 要减少**:探针失败不许静默关掉动效"
);
assert_eq!(
effective_scale(scale, ReduceMotion::Reduced, true),
scale,
"取证相位钉住时不被系统偏好降级(多帧序列图要可复现)"
);
}
}
// ------------------------------------------------------------------ 取证相位
/// `ULL_MOTION_AT` 的钉住相位:时间轴不前进,进度恒定(多帧序列图靠它可复现)。
///
/// 抓得住:把 `start_now`/`elapsed_ms` 的钉住分支删掉(进度会随时间漂,序列图不再可复现)。
/// 抓不住:**出图本身**——这条只证明"相位恒定",图得另外拍(见报告的未验清单)。
#[test]
fn pinned_phase_freezes_the_clock() {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, Some(150.0));
pump.observe(Some(1), Some(0), None); // 300ms 档 ⇒ 钉在 150 = 一半
for _ in 0..10 {
pump.advance(FRAME_MS);
}
let frame = pump.page_frame();
let expected = 16.0 * (1.0 - page_spec(Kind::Boot).enter.easing.sample(0.5));
assert!(
(frame.enter.1 - expected.round()).abs() < 1.0,
"钉在 150/300 的位移应约 {expected},实测 {}",
frame.enter.1
);
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();
}
/// **进场途中被关掉**:进度必须从**当前值**接着缩,不许先跳到 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();
}