Files
Unlucky-Launcher/crates/app-iced/src/components/motion/tests/mod.rs
T
AskaEth 0522a41a52
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.5
2026-11-12 12:06:59 +08:00

390 lines
15 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.
//! 帧泵与过渡表的守卫(M192 建,M256 扩)。全部用**本地** `Pump` 摆时间轴:不起窗口、
//! 不等真实时间,所以不会假绿。
//!
//! 接线级的守卫(真消息 / 真订阅表)在 `app/subscription.rs`:这一组只管算术与表。
//!
//! 每条用例都写清「抓得住什么 / 抓不住什么」(T27 之后的纪律)。判据是「泵帧数 + 端点 +
//! 单调性」,不是像素比对:像素只能证明"某一张图有没有偏移",证明不了"中间有 19 个相位"。
//!
//! 模态 / 系统偏好 / 降级那一半在 [`modal`]。
mod modal;
mod timeline;
use super::pump::Pump;
use super::transition::{page_spec, Kind, ModalFrame, PageFrame};
use super::*;
use unlucky_design::motion::MotionScale;
/// 标准档、没有钉相位的泵(出厂态是 `NONE`,见 [`Pump::new`])。
fn standard() -> Pump {
let mut pump = Pump::new();
pump.configure(MotionScale::STANDARD, None);
pump
}
/// 按「每帧 16 ms」推进到没有轨道为止,返回推进了多少帧(上限 1250 帧 = 20 s,防死循环)。
fn settle(pump: &mut Pump) -> u64 {
let mut frames = 0;
for _ in 0..1250 {
if !pump.is_animating() {
break;
}
if pump.advance(FRAME_MS) {
frames += 1;
}
}
frames
}
/// 一路采样所有中间态(含终点),用完自己 settle。
fn samples(pump: &mut Pump) -> Vec<PageFrame> {
let mut out = vec![pump.page_frame()];
while pump.is_animating() {
pump.advance(FRAME_MS);
out.push(pump.page_frame());
}
out
}
// ------------------------------------------------------------------ 规格表
/// 规格**全部**来自 design 的令牌表,且落在 M3E 的短档(150~300ms)。
///
/// 抓得住:谁把某档改成 400ms 以上("更显眼"的诱惑),或把幅度改成非 4 的倍数。
/// 抓不住:观感好不好——那要截图(D30),不在这条用例的能力范围里。
#[test]
fn every_transition_is_built_from_registered_tokens() {
let kinds = [
Kind::Boot,
Kind::Peer { forward: true },
Kind::Peer { forward: false },
Kind::Push,
Kind::Pop,
];
for kind in kinds {
let spec = page_spec(kind);
for leg in [spec.enter, spec.exit] {
assert!(
(150..=300).contains(&leg.duration.ms()),
"{}: {}ms 不在 MD3E 短档(150~300)",
kind.name(),
leg.duration.ms()
);
assert!(leg.easing.sample(0.0).abs() < 1.0e-6);
assert!((leg.easing.sample(1.0) - 1.0).abs() < 1.0e-6);
}
// 进略长于出(M3E:进场要看清,退场只是让位)
assert!(
spec.enter.duration.ms() > spec.exit.duration.ms(),
"{}: 进场必须比退场长(进 {} / 出 {})",
kind.name(),
spec.enter.duration.ms(),
spec.exit.duration.ms()
);
// 幅度落在 4px 栅格上,且退场幅度小于进场幅度
let (inx, iny) = spec.enter_from;
let (outx, outy) = spec.exit_to;
for value in [inx, iny, outx, outy] {
assert_eq!(value % 4.0, 0.0, "{}: {value} 不在 4px 栅格上", kind.name());
}
assert!(
inx.abs().max(iny.abs()) >= outx.abs().max(outy.abs()),
"{}: 退场幅度不该大于进场幅度",
kind.name()
);
}
// 四档的动作方向钉住(谁改方向,报告与出图都要跟着改)
assert_eq!(
page_spec(Kind::Peer { forward: true }).enter_from,
(12.0, 0.0)
);
assert_eq!(page_spec(Kind::Peer { forward: true }).exit_to, (-8.0, 0.0));
assert_eq!(
page_spec(Kind::Peer { forward: false }).enter_from,
(-12.0, 0.0)
);
assert_eq!(page_spec(Kind::Push).enter_from, (0.0, 16.0));
assert_eq!(page_spec(Kind::Push).exit_to, (0.0, -8.0));
assert_eq!(page_spec(Kind::Pop).enter_from, (0.0, -16.0));
}
// ------------------------------------------------------------------ 页面切换
/// **进入是 12~20 帧、落在端点、中途有严格中间态**(塔给的判据原文)。
///
/// 抓得住:①把时长改到 300ms 以外(帧数跑出窗口);②把 `enter_offset` 改成直接返回 0
/// ("硬跳",中间态消失);③把末帧没归零(`PageFrame::STILL` 的端点断言红)。
/// 抓不住:这一帧画出来好不好看(截图的事)。
#[test]
fn a_page_enters_over_twelve_to_twenty_frames_and_lands_on_the_endpoint() {
let mut pump = standard();
assert!(pump.observe(Some(2), Some(1), None), "首次观察要起跑 Boot");
let frames = settle(&mut pump);
assert!(
(12..=20).contains(&frames),
"进入应落在 12~20 帧,实测 {frames} 帧"
);
// 重跑一次,逐帧看端点与单调性
pump.observe(Some(3), Some(1), None); // 同级换页:medium2 = 300ms
let trace = samples(&mut pump);
assert!(trace.len() >= 12, "中间态太少:{}", trace.len());
assert_eq!(
trace.last().copied(),
Some(PageFrame::STILL),
"末帧必须归位"
);
assert_eq!(trace[0].enter, (12.0, 0.0), "首帧必须满幅(向右推进)");
assert!(
trace
.iter()
.skip(1)
.any(|f| f.enter.0 > 0.0 && f.enter.0 < 12.0),
"中途必须存在**严格介于两端之间**的相位"
);
// 单调收敛到 0(缓动是单调曲线,取整也不会反弹)
let mut previous = trace[0].enter.0;
for frame in trace.iter().skip(1) {
assert!(
frame.enter.0 <= previous,
"位移必须单调归零:{previous} → {}",
frame.enter.0
);
previous = frame.enter.0;
}
}
/// **旧页反向退场**:同一拍里新页从 +12 归零、旧页向 −8 走;退场腿更短,先结束。
///
/// 抓得住:把 `exit` 位移删掉/写成同向("只挪新页"),或让退场腿和进场腿一样长。
/// 抓不住:旧页**内容**是否正确(那是 `pages::page_with_settings_transition` 的职责,
/// 归 `app` 层的用例)。
#[test]
fn the_old_page_moves_the_other_way_and_leaves_first() {
let mut pump = standard();
pump.observe(Some(2), Some(1), None);
settle(&mut pump);
assert!(pump.observe(Some(3), Some(1), None));
let first = pump.page_frame();
assert_eq!(first.enter, (12.0, 0.0));
assert_eq!(first.exit, (0.0, 0.0), "退场起点是 0");
assert!(first.exiting, "退场腿刚开始");
let mut saw_half = false;
let mut exit_finished_at = None;
let mut frames = 0;
while pump.is_animating() {
frames += 1;
pump.advance(FRAME_MS);
let frame = pump.page_frame();
if frame.exit.0 < 0.0 && frame.exit.0 > -8.0 {
saw_half = true;
}
if !frame.exiting && exit_finished_at.is_none() {
exit_finished_at = Some(frames);
}
}
assert!(saw_half, "旧页必须有严格中间态(不是一步到位)");
let exit_frames = exit_finished_at.expect("退场腿必须在进场腿之前结束");
assert!(
exit_frames < frames,
"退场({exit_frames} 帧)必须比进场({frames} 帧)先结束"
);
}
/// **方向由导航深度决定**:同级换页走水平、进深层走纵向。
///
/// 抓得住:把四档动作表合并成一档("都往右下飘"),或把 `forward` 判据反了(回到左边却向右推)。
/// 抓不住:导航表本身的顺序对不对(那是 `pages::Screen::ALL` / `screen_key` 的事)。
#[test]
fn direction_follows_navigation_depth() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
// 向右推进(屏键 1 → 3)
pump.observe(Some(3), Some(1), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: true }));
assert_eq!(pump.page_frame().enter, (12.0, 0.0), "同级往右 = 水平位移");
settle(&mut pump);
// 向左退回(3 → 1)
pump.observe(Some(1), Some(0), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: false }));
assert_eq!(pump.page_frame().enter, (-12.0, 0.0), "同级往左 = 镜像");
settle(&mut pump);
// 进深层(同屏:列表 → 详情)
pump.observe(Some(1), Some(0), Some("instance-7"));
assert_eq!(pump.kind(), Some(Kind::Push));
let push = pump.page_frame();
assert_eq!(push.enter, (0.0, 16.0), "进深层 = 纵向(从下方上浮)");
assert_eq!(push.exit, (0.0, 0.0));
settle(&mut pump);
// 出深层(详情 → 列表)
pump.observe(Some(1), Some(0), None);
assert_eq!(pump.kind(), Some(Kind::Pop));
assert_eq!(pump.page_frame().enter, (0.0, -16.0), "出深层必须镜像");
}
/// **左右方向看导航轨序,不看屏键**(复审 P2-1 的守卫)。
///
/// 两处顺序不同:`Screen::ALL` 是 `… Content / Settings / Logs`,导航轨是 `… Logs / Settings / Ai`。
/// 用屏键判方向会让 **Logs↔Settings 镜像**。
///
/// 抓得住:把 `Pump::observe` 的方向改回「比较屏键」(`new > old`)——本用例里
/// Settings(键 4 / **序 3**) → Logs(键 5 / **序 2**) 会被判成向右推进,第一条断言立即红。
/// 也抓得住「谁调了 `Screen::ALL` 的顺序」:这里**只引用轨序**,与那张表的声明顺序解耦。
/// 抓不住:导航轨本身的视觉位置(那是 `shell/nav.rs` 的几何,归它的守卫)。
#[test]
fn direction_comes_from_the_nav_rail_not_the_screen_table() {
let mut pump = standard();
pump.observe(Some(4), Some(3), None); // Settings:屏键 4、轨序 3
settle(&mut pump);
// 轨序 3 → 2(向左)。若按屏键 4 → 5,会被判成向右 —— 那正是被修的 bug。
pump.observe(Some(5), Some(2), None);
assert_eq!(
pump.kind(),
Some(Kind::Peer { forward: false }),
"Settings → Logs 在导航轨上是向左"
);
assert_eq!(pump.page_frame().enter, (-12.0, 0.0));
settle(&mut pump);
// 反向:轨序 2 → 3 = 向右
pump.observe(Some(4), Some(3), None);
assert_eq!(pump.kind(), Some(Kind::Peer { forward: true }));
assert_eq!(pump.page_frame().enter, (12.0, 0.0));
}
/// **`MotionScale::NONE` 下位移与缩放严格为 0**(不是"很小")。
///
/// 抓得住:把 `play_page` 的 `is_off()` 分支删掉(变成跑一遍 0 时长动画)——那时 `frame()`
/// 会短暂给出非 0 的位移、`samples` 会增长、帧订阅次数不再是 0。
/// 抓不住:OS 偏好有没有真的读到(那是 `platform::motion` 的用例与真机取证)。
#[test]
fn none_scale_is_strictly_zero_not_merely_small() {
let mut pump = Pump::new(); // 出厂即 NONE
assert!(pump.scale().is_off());
// 首屏 / 换页 / 进退层:三次状态变化都不许留下轨道,位移必须 `== 0.0`(不是 `abs() < eps`)
for (screen, rank, detail) in [
(Some(1), Some(0), None),
(Some(2), Some(1), None),
(Some(2), Some(1), Some("instance-7")),
(Some(2), Some(1), None),
] {
assert!(!pump.observe(screen, rank, detail), "关掉动效时不许起跑");
let frame = pump.page_frame();
assert_eq!(frame.enter.0, 0.0);
assert_eq!(frame.enter.1, 0.0);
assert_eq!(frame.exit.0, 0.0);
assert_eq!(frame.exit.1, 0.0);
assert!(!frame.exiting, "NONE 档不许画旧页");
}
// 模态:进场直接到 1.0(缩放严格 1.0),退场直接关
assert!(!pump.observe_modal(true), "NONE 档不起模态轨道");
assert_eq!(
pump.modal_frame(),
ModalFrame {
visible: true,
progress: 1.0
}
);
assert!((modal_scale(1.0) - 1.0).abs() < 1.0e-6, "缩放严格 1.0");
assert!(pump.begin_modal_close(), "NONE 档必须**当场**关,不留退场");
assert_eq!(pump.modal_frame(), ModalFrame::HIDDEN);
assert!(!pump.is_animating());
for _ in 0..120 {
assert!(!pump.advance(FRAME_MS), "NONE 档没有任何可推进的");
}
assert_eq!(pump.samples(), 0, "NONE 档的帧采样次数必须是 0");
}
// ------------------------------------------------------------------ 触发判据
/// 重复绘制**永远不会**重新起跑(否则每帧都重启动画 = 永远停不下来)。
///
/// 抓得住:把「标识没变 ⇒ 不起跑」的判断写错(比如每个 view 都 `play_page`)——30 次重复绘制后
/// `is_animating()` 仍为真、帧泵永远停不下来,本条红。
/// 抓不住:真实屏幕上"重画是否真的只是重画"(那要看 iced 的订阅表,归 `app::subscription` 的用例)。
#[test]
fn repeated_paints_never_restart_the_animation() {
let mut pump = standard();
pump.observe(Some(3), Some(1), Some("instance-9"));
settle(&mut pump);
for _ in 0..30 {
assert!(!pump.observe(Some(3), Some(1), Some("instance-9")));
assert_eq!(pump.page_frame(), PageFrame::STILL);
}
assert!(!pump.is_animating());
}
/// 不归 pages 画的屏(账户 / AI 独立页)**不起跑**;回到某一屏照样跑。
///
/// 抓得住:把 `screen == None` 也当成一次换页起跑(会白跑几百毫秒的帧却没人采样)。
/// 抓不住:账户页 / AI 页自己的渲染(它们不走 `pages::render`,不归本 pump 管)。
#[test]
fn screens_rendered_elsewhere_never_start_a_track() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
assert!(
!pump.observe(None, None, None),
"没有 pages 树可动 ⇒ 不许白开帧"
);
assert!(!pump.is_animating());
assert!(
pump.observe(Some(1), Some(0), None),
"从账户页回来仍然是换页"
);
assert_eq!(pump.kind(), Some(Kind::Boot), "没有旧页可退 ⇒ 按首屏方向");
}
/// **过渡中途再换页要能重入**(塔的门槛 D):新的进场、旧的立刻被取代,不叠加、不卡死。
///
/// 抓得住:把「新轨道取代旧轨道」改成 push(位移会叠加成 24px)、或让旧轨道继续占帧
/// (帧数会明显超出 12~20 那一档)。
/// 抓不住:`App::previous_view` 的丢弃(那条在 `app/update/motion_sync.rs` 的用例里)。
#[test]
fn a_second_navigation_mid_flight_takes_over_cleanly() {
let mut pump = standard();
pump.observe(Some(1), Some(0), None);
settle(&mut pump);
pump.observe(Some(2), Some(1), None); // 往右推进
for _ in 0..4 {
pump.advance(FRAME_MS); // 走 3~5 帧就再点一次
}
let mid = pump.page_frame();
assert!(
mid.enter.0 < 12.0 && mid.enter.0 > 0.0,
"第一次过渡还在半路"
);
pump.observe(Some(1), Some(0), None); // 立刻退回左边
let restarted = pump.page_frame();
assert_eq!(restarted.enter, (-12.0, 0.0), "新方向立刻生效(满幅起点)");
assert_eq!(
restarted.exit,
(0.0, 0.0),
"旧页从 0 重新开始,不与上一次叠加"
);
let frames = settle(&mut pump);
assert!(
(12..=20).contains(&frames),
"重入之后仍要在 12~20 帧内停下,实测 {frames}"
);
assert_eq!(pump.page_frame(), PageFrame::STILL);
}