A2 侧边栏(右栗 AI 栏)折叠/展开: - 帧泵第三条轨道(pump/rail):展开 medium1 250ms + Emphasized、收起 short4 200ms + EmphasizedAccelerate;拆出档走 snap(不做过渡);MotionScale::NONE 下不插轨道 - 宽度在 metrics::rail_width 逐帧插值;面板内容按目标宽排版 + 按动画宽裁切 (不重排 / 不溢出),宽度回到 48 时换成图标条(图标贴右端,位置不跳) - pages/agent/mod.rs 的展开档面板纯搬移到 panel.rs(371 → 289 行) A8 状态层过渡: - interaction/layer:令牌化的进度时钟(short1 50ms),NONE 当帧落终值 - interaction/hover:取样控件把起跑搬到 Widget::update(早于订阅表求值) - 按钮五式 + IconButton + 导航项药丸统一走 interaction::layer_color (没有取样控件时逐位退回旧口径,出图基线不变) - components/motion/mod.rs 的 needs_frames / advance 各加一句委托, 帧订阅仍然只有一处判断点(app/subscription.rs 未改)
175 lines
6.9 KiB
Rust
175 lines
6.9 KiB
Rust
//! 右栗折叠轨道(M257 / A2)的判据:泵帧数 + 端点精确 + 中间态严格单调。
|
||
//!
|
||
//! 判据口径同 M256(`14-motion-and-animation.md` §7-7):**不靠像素比对**。像素只能证明「某一张图
|
||
//! 里宽度是多少」,证明不了「中间有 12 个相位」与「结束时不多要一帧」。
|
||
|
||
use super::{settle, standard};
|
||
use crate::app::components::motion::*;
|
||
use unlucky_design::motion::MotionScale;
|
||
|
||
/// 一路采样折叠进度(含终点),用完自己 settle。
|
||
fn samples(pump: &mut Pump) -> Vec<f32> {
|
||
let mut out = vec![pump.rail_progress()];
|
||
let mut guard = 0;
|
||
while pump.is_animating() {
|
||
pump.advance(FRAME_MS);
|
||
out.push(pump.rail_progress());
|
||
guard += 1;
|
||
assert!(guard < 200, "折叠轨道没在跑完,疑似终止条件失效");
|
||
}
|
||
out
|
||
}
|
||
|
||
/// **展开:帧数落在 12~20 之间、端点精确、中间严格单调**。
|
||
///
|
||
/// 抓得住:①有人把 `medium1`(250) 改成 600ms(帧数窜到 38)或改成 0(硬跳,帧数 0);
|
||
/// ②终止条件被删掉(`while is_animating` 直接跑满 200 帧的兜底断言红);③插值写成阶跃
|
||
/// (单调性断言里的 `>` 会变成 `==`)。
|
||
/// 抓不住:观感是否「像 M3E」——那要出图(D30),本用例只保证相位数量与端点。
|
||
#[test]
|
||
fn expanding_runs_the_expected_number_of_frames_and_lands_exactly_on_one() {
|
||
let mut pump = standard();
|
||
assert!(pump.observe_rail(true), "目标变了必须起跑");
|
||
assert_eq!(pump.rail_progress(), 0.0, "第一帧还在折叠档(端点精确)");
|
||
|
||
let values = samples(&mut pump);
|
||
let frames = values.len() - 1;
|
||
assert!(
|
||
(12..=20).contains(&frames),
|
||
"250ms / 16ms 应落在 12~20 帧,实测 {frames} 帧"
|
||
);
|
||
assert_eq!(
|
||
values.last().copied(),
|
||
Some(1.0),
|
||
"结束时必须**严格**是 1.0(不是 0.99x)"
|
||
);
|
||
for pair in values.windows(2) {
|
||
assert!(
|
||
pair[1] > pair[0],
|
||
"展开必须严格单调:{:?} → {:?}",
|
||
pair[0],
|
||
pair[1]
|
||
);
|
||
}
|
||
assert!(!pump.is_animating(), "跑完必须清空轨道(否则空闲烧帧)");
|
||
assert_eq!(pump.rail_progress(), 1.0, "没有轨道时是终值");
|
||
}
|
||
|
||
/// **收起:同一条判据,反向**(`short4` 200ms ⇒ 12~13 帧)。
|
||
#[test]
|
||
fn collapsing_is_strictly_monotone_downwards_and_stops_at_zero() {
|
||
let mut pump = standard();
|
||
pump.observe_rail(true);
|
||
settle(&mut pump);
|
||
assert_eq!(pump.rail_progress(), 1.0);
|
||
|
||
assert!(pump.observe_rail(false));
|
||
let values = samples(&mut pump);
|
||
let frames = values.len() - 1;
|
||
assert!(
|
||
(10..=20).contains(&frames),
|
||
"200ms / 16ms 应落在 10~20 帧,实测 {frames} 帧"
|
||
);
|
||
assert_eq!(values.last().copied(), Some(0.0), "结束时必须严格是 0.0");
|
||
for pair in values.windows(2) {
|
||
assert!(pair[1] < pair[0], "收起必须严格单调下降");
|
||
}
|
||
assert!(!pump.is_animating());
|
||
}
|
||
|
||
/// **`MotionScale::NONE`:严格等于终值,且一帧都不要**(独立的用例,不靠别的用例顺带覆盖)。
|
||
///
|
||
/// 抓得住:把 `observe_rail` 里的 `is_off()` 分支删掉(那会插一条 0 时长轨道 ⇒ 进度仍是 1.0,
|
||
/// 但 `is_animating()` 会有一拍为真 ⇒ 本用例的「一帧都不要」断言红);或把 `is_off()`
|
||
/// 写成「跑一遍 0 时长」(同样红)。
|
||
/// 抓不住:中间帧**该不该**存在(NONE 下不该有中间帧,这是定义,不是观感)。
|
||
#[test]
|
||
fn motion_scale_none_puts_the_rail_strictly_at_the_end_value() {
|
||
let mut pump = Pump::new();
|
||
pump.configure(MotionScale::NONE, None);
|
||
|
||
assert!(
|
||
!pump.observe_rail(true),
|
||
"NONE 下不许起跑轨道(不是什么都没发生,是**不许有中间态**)"
|
||
);
|
||
assert_eq!(pump.rail_progress(), 1.0, "展开档的终值必须逐位是 1.0");
|
||
assert!(!pump.is_animating(), "NONE 下一帧都不许要");
|
||
|
||
assert!(!pump.observe_rail(false));
|
||
assert_eq!(pump.rail_progress(), 0.0, "折叠档的终值必须逐位是 0.0");
|
||
assert!(!pump.is_animating());
|
||
}
|
||
|
||
/// **中途反向不跳变**:折叠到一半改主意展开,下一帧的进度必须从当前值继续(不是回 0/1)。
|
||
///
|
||
/// 抓得住:把 `observe_rail` 里的 `from` 写成常量 0 / 1(那会让宽度在半路跳一下)。
|
||
/// 抓不住:真实手感(那个「拐点」顺不顺)——只保证数值连续。
|
||
#[test]
|
||
fn reversing_mid_flight_continues_from_the_current_progress() {
|
||
let mut pump = standard();
|
||
pump.observe_rail(true);
|
||
for _ in 0..4 {
|
||
pump.advance(FRAME_MS);
|
||
}
|
||
let before = pump.rail_progress();
|
||
assert!(
|
||
before > 0.1 && before < 0.9,
|
||
"前提:应该处在中间(实测 {before})"
|
||
);
|
||
|
||
pump.observe_rail(false);
|
||
let after = pump.rail_progress();
|
||
assert!(
|
||
(after - before).abs() < 1e-6,
|
||
"反向那一帧不许跳变:{before} → {after}"
|
||
);
|
||
}
|
||
|
||
/// **拆出档不走过渡**:`snap_rail` 直接落终值,且把在跑的轨道掐掉。
|
||
///
|
||
/// 抓得住:有人把 `Detached` 也接进这条轨道(那会做一次「48 → 0」的缩水动画)。
|
||
/// 抓不住:拆分本身的观感(那是布局切换,不在动效范围里)。
|
||
#[test]
|
||
fn detaching_snaps_instead_of_animating() {
|
||
let mut pump = standard();
|
||
pump.observe_rail(true);
|
||
pump.advance(FRAME_MS);
|
||
assert!(pump.is_animating());
|
||
|
||
assert!(pump.snap_rail(false), "有轨道在跑时落档要报「掐掉了」");
|
||
assert_eq!(pump.rail_progress(), 0.0);
|
||
assert!(!pump.is_animating());
|
||
}
|
||
|
||
/// **帧订阅的委托**:状态层过渡在跑时 `motion::needs_frames()` 必须为真。
|
||
///
|
||
/// 这是 `app/subscription.rs` 唯一的判断点(`motion::needs_frames()`):A8 的时钟若没被委托进
|
||
/// 这里,产品里就一帧都不会来(状态层卡在起始值),而单元测试照样能靠 `advance_by` 摆出绿。
|
||
///
|
||
/// 抓得住:删掉 `motion::needs_frames` 里对 `interaction::layer::is_animating()` 的那一句。
|
||
/// 抓不住:真实窗口的帧率(那由 `window::frames()` 决定,见 `app/subscription.rs` 的表)。
|
||
#[test]
|
||
fn the_state_layer_clock_feeds_the_single_subscription_point() {
|
||
use crate::app::components::interaction::layer;
|
||
|
||
reset();
|
||
assert!(!needs_frames(), "前提:静息时不要帧");
|
||
|
||
let mut state = layer::Layer::new();
|
||
state.retarget(
|
||
crate::app::components::interaction::Interaction::Hovered,
|
||
MotionScale::STANDARD,
|
||
);
|
||
assert!(
|
||
needs_frames(),
|
||
"悬停起跑之后**同一拍**就要帧(否则 draw 侧起跑的 0 进度会一直画不出来)"
|
||
);
|
||
|
||
// 推进到 50ms 之后:必须自己退订(不是「一直要帧」)。
|
||
for _ in 0..6 {
|
||
layer::advance_by(FRAME_MS);
|
||
}
|
||
assert!(!needs_frames(), "过渡走完必须立刻不要帧");
|
||
layer::reset();
|
||
}
|