Files
Unlucky-Launcher/crates/app-iced/src/components/interaction/layer/tests.rs
T
AskaEth 9780fca1c7 fix(app-iced): blend 用例端点改用真实令牌色(M288)
`blend_hits_both_endpoints_bit_for_bit` 原先用 `Color::from_rgb(0.1, 0.2, 0.3)` /
`(0.7, 0.8, 0.9)` 当端点,撞上 `tests/no_hardcoded_tokens.rs` 的色值守门(它扫 `src/**`,
测试模块也算在里面),使 base 上「全量测试绿」永不可达。

修法在**被判的那一侧**:端点取状态层真正会插值的一对角色——`Role::Surface`(容器)与
`Role::OnSurface`(内容)——经产品同一条令牌路径(`BrandBoard::for_flavor(..).scheme()`
→ `to_iced`)取值,浅/深两套板各跑一遍;判据值全部现算(每通道取两端 min/max)。

三条判据一条不少:端点逐位相等、中点**严格**落在两端之间(比原先的闭区间更强)、α 全程 1.0。
守卫文件一行未改(仍 400 行),扫描范围没有放宽。
2026-11-12 23:55:24 +08:00

218 lines
10 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 crate::theme::to_iced;
use unlucky_design::color::{BrandBoard, Role, SchemeFlavor};
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("刚压进去的层必须在栈顶"))
}
/// 两个不透明令牌色的插值:**端点逐位相等**,中点严格落在两者之间。
///
/// ## 端点为什么是角色,而不是 `Color::from_rgb(…)` 写死的数值
///
/// 这两行原先随手写了 `from_rgb(0.1, 0.2, 0.3)` / `from_rgb(0.7, 0.8, 0.9)`,撞上
/// `tests/no_hardcoded_tokens.rs` 的色值守门(它扫 `src/**`,测试模块也算在里面)。修法是
/// **改测试、不动守门**:端点取状态层真正会插值的那一对——容器 [`Role::Surface`] 与内容
/// [`Role::OnSurface`]——经产品同一条令牌路径(`Scheme::get` → [`to_iced`])翻成 iced 色。
///
/// ⚠️ 本文件里连**注释**都不许出现「构造名 + 数字」的写法:守门按行做子串匹配,不区分代码与
/// 文档(`the_gate_itself_catches_a_planted_violation` 也把这条钉住了)。要举例就写 `from_rgb(…)`。
///
/// ## 为什么这不算「削弱守卫」(将来别顺手把测试全排除)
///
/// 守卫的规则是「组件层的颜色一律来自 `unlucky_design` 的 `Role`」,它不区分测试代码与产品
/// 代码——也不该区分:测试里的色字面量**就是**组件层源码里长出来的一串数字,例外一旦开在
/// 「测试」名下,下一个「临时」的 `from_rgb` 只会多一个借口。所以这里不削弱、不豁免、不给守卫
/// 加任何例外:**扫描范围一行没改**,只是把被它正确抓到的那两行换成令牌色。
///
/// 反过来说,按路径排除 `*/tests.rs` 也排不干净:`#[cfg(test)] mod tests` 与产品代码**同文件**
/// (本模块就是 `layer.rs` 末尾的 `#[cfg(test)] mod tests;`),路径分不开「同文件里的测试」与
/// 「同文件里的产品代码」;而任何文件都能被改名叫 `tests.rs` 来藏字面量。结论:守卫保持全量
/// 覆盖,误判在**被判的那一侧**修。
///
/// ## 判据(三条一条不少)
///
/// 1. 端点逐位相等(`p = 0 / 1` 直接返回原值,不许「差不多」);
/// 2. 中点**严格**落在两端之间(每个通道用两端现算的 `min`/`max`,不是抄来的数字);
/// 3. α 全程 1.0(D27 的「两个后端逐像素一致」就靠这条守着)。
///
/// 浅/深两套品牌板各跑一遍,判据值全部现算 ⇒ 换板、换品牌色都不会让这条用例变哑。
///
/// 抓得住:①把端点写成「差不多」(`p == 0` 时返回插值结果 0.999…)——逐位断言红;
/// ②引入 α(`a` 不再是 1.0)——第 3 条红;③插值写成线性之外的东西(端点仍对、中点越界)——第 2 条红。
/// 抓不住:色值本身好不好看(那是角色映射的事)。
#[test]
fn blend_hits_both_endpoints_bit_for_bit() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let from = board_color(flavor, Role::Surface);
let to = board_color(flavor, Role::OnSurface);
// 「严格介于」的前提:两端不透明,且每个通道真的不同(分量可排序)。
assert_eq!(
(from.a, to.a),
(1.0, 1.0),
"{flavor:?}:Surface / OnSurface 必须都不透明(D27)"
);
assert!(
differs_in_every_channel(from, to),
"{flavor:?}:两端有通道相同({from:?} / {to:?}),中点不可能严格介于——换一对角色"
);
assert_eq!(
blend(from, to, 0.0),
from,
"{flavor:?}:p = 0 必须逐位等于起点"
);
assert_eq!(
blend(from, to, 1.0),
to,
"{flavor:?}:p = 1 必须逐位等于终点"
);
for p in [0.25, 0.5, 0.75] {
let mid = blend(from, to, p);
for (name, value, start, end) in [
("r", mid.r, from.r, to.r),
("g", mid.g, from.g, to.g),
("b", mid.b, from.b, to.b),
] {
let (lo, hi) = (start.min(end), start.max(end));
assert!(
value > lo && value < hi,
"{flavor:?} p = {p}:{name} 必须**严格**落在两端之间({lo} < {value} < {hi})"
);
}
assert_eq!(mid.a, 1.0, "中间态也不许引入半透明(D27)");
}
}
}
/// 品牌板上某个角色的 iced 颜色(与产品同一条令牌路径)。
fn board_color(flavor: SchemeFlavor, role: Role) -> iced::Color {
to_iced(BrandBoard::for_flavor(flavor).scheme().get(role))
}
/// 三个通道是否都不同——只有全不同,「严格介于」才有定义。
fn differs_in_every_channel(from: iced::Color, to: iced::Color) -> bool {
from.r != to.r && from.g != to.g && from.b != to.b
}