复审 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 即红。
316 lines
13 KiB
Rust
316 lines
13 KiB
Rust
//! 模态 / 系统偏好 / 有界泵的降级守卫(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();
|
||
}
|