release: v0.2.5
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
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
This commit is contained in:
commit
0522a41a52
1587 files changed
+323674
No files matched your search
@@ -0,0 +1,389 @@
|
||||
//! 帧泵与过渡表的守卫(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);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! 模态 / 系统偏好 / 有界泵的降级守卫(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();
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 有界泵的算术守卫(M256 从 `tests/mod.rs` 拆出,守 400 行硬线):采样频率上限、到期即退订、
|
||||
//! stale 帧基准不许一帧杀完、以及 `MotionScale::HALF` 的**语义**(时长减半 ≠ 半速)。
|
||||
//!
|
||||
//! 这些全是「本地 `Pump` + 摆时间轴」就能判死的算术性质,所以不需要窗口、不需要真实时间。
|
||||
|
||||
use super::super::pump::{Pump, FRAME_MS, MAX_STEP_MS};
|
||||
use super::super::transition::PageFrame;
|
||||
use super::{settle, standard};
|
||||
use unlucky_design::motion::{DurationToken, MotionScale};
|
||||
|
||||
// ------------------------------------------------------------------ 有界泵
|
||||
|
||||
/// **空闲不烧帧**:动画跑完 ⇒ `is_animating()` 立刻为假,且不再推进。
|
||||
///
|
||||
/// 抓得住:把「到期即删除」删掉(轨道永远留着 ⇒ 帧订阅永不退订)。
|
||||
/// 抓不住:整窗重绘次数(那跟随渲染器,见 `FRAME_MS` 的文档)。
|
||||
#[test]
|
||||
fn a_finished_animation_stops_asking_for_frames() {
|
||||
let mut pump = standard();
|
||||
assert!(pump.observe(Some(1), Some(0), None));
|
||||
assert!(pump.is_animating(), "起跑后必须有轨道(订阅帧的唯一理由)");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(!pump.is_animating(), "动画结束必须**当场**退订");
|
||||
assert_eq!(pump.page_frame(), PageFrame::STILL);
|
||||
|
||||
let before = pump.samples();
|
||||
for _ in 0..60 {
|
||||
assert!(!pump.advance(FRAME_MS), "没有轨道时推进必须返回 false");
|
||||
}
|
||||
assert_eq!(pump.samples(), before, "空闲态一次采样都不该发生");
|
||||
}
|
||||
|
||||
/// **采样频率上限**:帧到得再快,时间轴也只按 16 ms 结算(≈62.5 次/秒)。
|
||||
///
|
||||
/// 抓得住:把 `pending_ms` 那道闸门删掉(每来一帧就推进 ⇒ 软件渲染下几百次/秒的重排)。
|
||||
/// 抓不住:**整窗重绘**频率——那跟随渲染器,与这条闸门无关。
|
||||
#[test]
|
||||
fn frames_are_capped_at_sixty_per_second() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), Some(0), None);
|
||||
|
||||
let mut advanced = 0;
|
||||
for _ in 0..200 {
|
||||
if pump.advance(1.0) {
|
||||
advanced += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
(11..=13).contains(&advanced),
|
||||
"200 ms 内应 12±1 次推进,实测 {advanced}"
|
||||
);
|
||||
assert_eq!(pump.samples(), advanced);
|
||||
assert!(
|
||||
(pump.now_ms() - 192.0).abs() <= 16.0,
|
||||
"时间轴应约 192 ms,实测 {}",
|
||||
pump.now_ms()
|
||||
);
|
||||
}
|
||||
|
||||
/// **stale 帧基准不许把轨道一帧杀完**(M192 r1 复审 P1-1 的算术面)。
|
||||
///
|
||||
/// 抓得住:把 `MAX_STEP_MS` 的夹取删掉(一次 60 s 的 dt 会把轨道当场杀掉 ⇒ 动画一帧走完)。
|
||||
/// 抓不住:真机上「空闲醒来第一帧的 dt 到底多长」(那要真窗口,见报告的未验清单)。
|
||||
#[test]
|
||||
fn a_stale_baseline_cannot_kill_the_track_in_one_sample() {
|
||||
let mut pump = standard();
|
||||
pump.observe(Some(1), Some(0), None);
|
||||
|
||||
assert!(pump.advance(60_000.0)); // 60 秒的"空闲间隔"当 dt
|
||||
assert!(pump.is_animating(), "一次超长 dt 不许把轨道当场删掉");
|
||||
assert!(pump.now_ms() <= MAX_STEP_MS + 1.0e-6);
|
||||
assert!(pump.page_frame().enter.1 > 0.0, "进度不许一步到位");
|
||||
|
||||
settle(&mut pump);
|
||||
assert!(!pump.is_animating());
|
||||
}
|
||||
|
||||
/// 半时长档(CPU 渲染兜底)真的把时长减半:同样的推进,轨道更早结束。
|
||||
///
|
||||
/// 顺带钉住 `MotionScale::HALF` 的**语义**(M256 核清项②):是「时长 ×0.5」=更快,
|
||||
/// 不是"半速"。抓得住:把 `HALF.factor` 改成 2.0(半速)或 1.0(无效)。
|
||||
/// 抓不住:**观感上"快一倍"是否合适**(那是 D30 的截图/录屏判断,不是断言)。
|
||||
#[test]
|
||||
fn half_scale_halves_the_duration_not_the_speed() {
|
||||
assert_eq!(MotionScale::HALF.factor(), 0.5);
|
||||
assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0);
|
||||
|
||||
let mut full = standard();
|
||||
full.observe(Some(1), Some(0), None);
|
||||
let full_frames = settle(&mut full);
|
||||
|
||||
let mut half = Pump::new();
|
||||
half.configure(MotionScale::HALF, None);
|
||||
half.observe(Some(1), Some(0), None);
|
||||
let half_frames = settle(&mut half);
|
||||
|
||||
assert!(half_frames > 0 && full_frames > 0);
|
||||
assert!(
|
||||
half_frames * 2 <= full_frames + 2,
|
||||
"HALF 应约用一半帧数:full={full_frames} half={half_frames}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user