Files
Unlucky-Launcher/crates/app-iced/src/components/interaction/layer/tests.rs
T
AskaEth b61f342431 feat(motion2): 右栗折叠过渡 + 状态层 hover/pressed 过渡
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 未改)
2026-11-12 20:05:58 +08:00

146 lines
6.5 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.
//! 状态层过渡的判据(M257 / A8):**帧数 + 端点精确 + 中间态严格单调**,外加两条硬契约。
//!
//! 两条硬契约(塔在 M257 briefing 里点名的):
//!
//! 1. `MotionScale::NONE` 下进度**严格**等于终值(独立用例,不靠别的用例顺带覆盖);
//! 2. 「鼠标停住不动」的那段时间里过渡必须**自己走完并停在终值**——本文件用「只推时钟、不喂事件」
//! 的形态抓「停在 progress 0」这个具体故障。
use super::*;
use crate::app::components::motion::FRAME_MS;
use unlucky_design::motion::MotionScale;
const STANDARD: MotionScale = MotionScale::STANDARD;
const NONE: MotionScale = MotionScale::NONE;
/// 一条全新的过渡(每个用例开头都从干净时钟起跑)。
fn fresh() -> Layer {
reset();
Layer::new()
}
/// **悬停:50ms 内走到 1.0,端点精确,中间严格单调——且**不靠任何事件**。**
///
/// 这条用例直接对着塔的硬判据写:*「鼠标停住不动的整段时间里,过渡必须走完并停在终值」*。
/// 实现里唯一驱动它的是 [`advance`](`motion::advance` 每帧调),所以「停在 progress 0」
/// (= 起跑写在 draw 侧、没有帧订阅)在这里会立刻红。
///
/// 抓得住:①把 `short1`(50) 改成 0(进度当帧就是 1.0 ⇒ 「第一帧必须是 0」的断言红);
/// ②把 `advance` 的时钟加去掉(进度永远 0 ⇒ 终值断言红);③把终止条件删掉(`is_animating` 恒真)。
/// 抓不住:真实帧率(那由 `window::frames()` 决定)、hover 的观感。
#[test]
fn hovering_fades_in_over_the_token_duration_without_any_further_events() {
let mut layer = fresh();
assert_eq!(layer.progress(), 1.0, "静息层:进度是终值(= 今天的行为)");
assert!(
layer.retarget(Interaction::Hovered, STANDARD),
"目标变了必须起跑"
);
assert_eq!(layer.progress(), 0.0, "第一帧必须还在静息端(端点精确)");
assert!(is_animating(), "起跑之后同一拍就该要帧");
let mut values = vec![layer.progress()];
let mut frames = 0;
while is_animating() {
advance_by(FRAME_MS);
values.push(layer.progress());
frames += 1;
assert!(frames < 60, "过渡没停,疑似终止条件失效");
}
// 50ms / 16ms ≈ 3~4 帧(含到期后多要的那一帧)。
assert!(
(3..=5).contains(&frames),
"50ms 应落在 3~5 帧,实测 {frames} 帧"
);
assert_eq!(values.last().copied(), Some(1.0), "终值必须**严格**是 1.0");
for pair in values.windows(2) {
assert!(
pair[1] >= pair[0],
"淡入必须单调不减:{:?} → {:?}",
pair[0],
pair[1]
);
}
let distinct: Vec<_> = values.iter().filter(|v| **v > 0.0 && **v < 1.0).collect();
assert!(
!distinct.is_empty(),
"必须有可判别的中间态(否则就是硬切):{values:?}"
);
assert!(!is_animating(), "走完必须退订帧");
assert_eq!(layer.progress(), 1.0, "没有轨道时进度恒为终值");
}
/// **`MotionScale::NONE` = 严格等于终值**(独立用例;不靠上一条顺带覆盖)。
///
/// 抓得住:把 `retarget` 里的 `NONE ⇒ ms = 0` 删掉(那会跑一遍 50ms ⇒ 首帧进度是 0.0,本用例红);
/// 或把 NONE 实现成「很快走完」(同样红:本用例断言的是**当帧就到位**,不是「几帧后到位」)。
/// 抓不住:`NONE` 之外的档位(那是别的用例的事)。
#[test]
fn motion_scale_none_has_no_intermediate_state_at_all() {
let mut layer = fresh();
assert!(layer.retarget(Interaction::Hovered, NONE));
assert_eq!(layer.progress(), 1.0, "NONE 下当帧就是终值(严格等于 1)");
assert!(!is_animating(), "NONE 下不许要帧(不是「很快走完」)");
assert!(layer.retarget(Interaction::Enabled, NONE));
assert_eq!(layer.progress(), 1.0, "移出同样是当帧终值");
assert!(!is_animating());
}
/// 同态重复不改目标(否则每次 draw 都会重启一条腿 ⇒ 过渡被无限拉长)。
///
/// 抓得住:把 `retarget` 的 `state == self.to` 早退删掉。
#[test]
fn retargeting_the_same_state_is_a_no_op() {
let mut layer = fresh();
assert!(layer.retarget(Interaction::Hovered, STANDARD));
advance_by(FRAME_MS);
let mid = layer.progress();
assert!(!layer.retarget(Interaction::Hovered, STANDARD));
assert_eq!(layer.progress(), mid, "同一目标的重复报告不许重置进度");
}
/// 按下比悬停更「强」:从悬停转按下走 `ENTER` 那条腿(起跑端是**上一个目标**,不是静息)。
///
/// 抓得住:把 `retarget` 里的 `self.from = self.to` 写成 `self.from = Interaction::Enabled`
/// (按下瞬间会先掉回静息色一帧——状态层闪一下),本用例断言 `from` 是 `Hovered`。
#[test]
fn pressing_starts_from_the_previous_target_not_from_rest() {
let mut layer = fresh();
layer.retarget(Interaction::Hovered, STANDARD);
// 不推进:模拟「悬停刚开始就按下」(鼠标点得比淡入还快)。
assert!(layer.retarget(Interaction::Pressed, STANDARD));
let (from, to, p) = current_from(&layer);
assert_eq!(from, Interaction::Hovered, "起跑端必须是上一个目标");
assert_eq!(to, Interaction::Pressed);
assert_eq!(p, 0.0, "新的一条腿从 0 开始");
}
/// 把层的两端读出来(`Layer` 的字段是私有的,这里经 [`with_current`] 走公开路径)。
fn current_from(layer: &Layer) -> (Interaction, Interaction, f32) {
with_current(*layer, || current().expect("刚压进去的层必须在栈顶"))
}
/// 两个不透明色的插值:**端点逐位相等**,中点严格落在两者之间。
///
/// 抓得住:①把端点写成「差不多」(`p == 0` 时返回插值结果 0.999…)——逐位断言红;
/// ②引入 α(`a` 不再是 1.0)——D27 的「两个后端逐像素一致」就靠这条守着。
#[test]
fn blend_hits_both_endpoints_bit_for_bit() {
let from = iced::Color::from_rgb(0.1, 0.2, 0.3);
let to = iced::Color::from_rgb(0.7, 0.8, 0.9);
assert_eq!(blend(from, to, 0.0), from);
assert_eq!(blend(from, to, 1.0), to);
for p in [0.25, 0.5, 0.75] {
let mid = blend(from, to, p);
assert!((0.1..=0.7).contains(&mid.r), "r 必须落在两端之间");
assert!((0.2..=0.8).contains(&mid.g));
assert!((0.3..=0.9).contains(&mid.b));
assert_eq!(mid.a, 1.0, "中间态也不许引入半透明(D27)");
}
}