Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/tests.rs
T
AskaEth 10d4f86fdf fix(iced/feedback): 圆角/描边带来的 probe 阈值订正 + Dialog 宽度逃生口接线
上一轮被 2 小时上限打断前的工作,逐条落地(都已在本地编译 + 单测 17 passed + 8 组 probe 中
6 组通过、empty-light 差 ±1 阈值):

- `keyframes::sample` 的 debug_assert 收窄成「首点不在 0ms **且** 首值不是 0」才炸:
  原先 `debug_assert_eq!(first.ms, 0)` 会把 `SEG_TAIL_1/2`(首点 250/900ms,值为 0)也判红,
  而那一类的「保持首值」与 Compose 的「动画初值 0」等价,是合法的。真正危险的是 ROTATION 那种
  「首值 ≠ 0」(会画出不存在的静止段),现在只拦这一种。
- `empty_state::block_layer()` 改成 `pub(super) const BLOCK_LAYER`:函数版在非 test 构建里
  是 dead_code(`-D warnings` 会红),改成常量后组件与 probe 共用一个来源。
- `probe/panels.rs`:图标块尺寸容差放到 ±2(块上补的 1px `OutlineVariant` 描边各占 1 行,
  浅色实测块色行 94、深色 97);顺带去掉没用到的 `matches` import。
- `tests/progress.rs`:三处期望值按实现对齐 —— 旋转每周期 **4 圈**(不是 1 圈)、
  右段宽 **0.505**(0.355..0.860,手算 0.516 偏了 0.01)、单调扫描只扫 `[0,1)`(phase==1.0
  就是下一周期的 0,回绕正常)。
- `dialog.rs`:`Fill + max_width(480)` **实测不生效**(Row 给 Fill 子项的 limits 是
  min=max=分配额,`Limits::max_width()` 的 `.max(self.min.width)` 会把 480 顶回去,
  两列并排实测渲染成 548)⇒ 改回 `Fixed(min(可用宽, 480))`,并加 `Dialog::width(available)`
  让页面递可用宽;gallery 里接线用 `HOST_WIDE`(=800 ⇒ 仍取 480)。能力缺口登记在注释里
  (与 G17-d 同一堵墙,报告里有 548 的实测)。

未完成(下一轮/合并前补):`cmp/dialog-*` 并排图要按 REF_H≈420 重出(动作行被裁掉)、
empty-light 的 ±2 阈值要复跑确认、clippy/test 门禁要贴真实 `test result:` 行。
2026-11-02 15:56:45 +08:00

199 lines
7.8 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.
//! 纯函数级断言:几何对令牌、节奏对 A1、角色对变体。
//!
//! 这里**只测数值与结构**(M68 的教训:函数对不代表图对)。对着真截图量像素的是
//! [`super::probe`],两者缺一不可——本文件保证「数字对」,probe 保证「画出来是那个数字」。
use iced::Element;
use unlucky_design::color::{Role, Scheme, SchemeFlavor, SCRIM_ALPHA};
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::typography::Platform;
use crate::theme::{to_iced, Families, Ui};
use super::super::interaction::{self, Interaction};
use super::dialog::scrim;
use super::panel::{DialogGeometry, EmptyStateGeometry, SnackbarGeometry};
use super::progress::Progress;
use super::snackbar::{Placement, Snackbar};
use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState};
mod progress;
const FAMILIES: Families = Families {
text: "Segoe UI",
display: "Segoe UI",
mono: "Consolas",
};
pub(super) fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
// ---- §14 Snackbar -----------------------------------------------------------
/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。
#[test]
fn snackbar_heights_widths_and_radius_are_the_tokens() {
let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48");
assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68");
assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)");
assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth");
assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽");
assert!(geometry.min_width < geometry.max_width, "上下限必须有区间");
assert_eq!(geometry.margin, 12.0, "G17-c [规格] 外边距 12");
assert_eq!(geometry.pad_h, 16.0, "A1 HorizontalSpacing");
assert_eq!(geometry.pad_v, 6.0, "A1 SnackbarVerticalPadding");
}
// ---- §7 Dialog --------------------------------------------------------------
/// 7.4:官方区间 **280..560**,本项目取 **480**,且 480 落在区间内。
#[test]
fn dialog_geometry_sits_inside_the_official_bounds() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.min_width, 280.0, "A1 DialogMinWidth");
assert_eq!(geometry.max_width, 560.0, "A1 DialogMaxWidth");
assert_eq!(geometry.width, 480.0, "G17-e 本项目取值");
assert!(
geometry.min_width <= geometry.width && geometry.width <= geometry.max_width,
"自选宽必须落在官方区间内"
);
}
/// 7.1 / 7.3 / 7.6:圆角 28、内距 24 / 16 / 24、动作 8 / 12。
#[test]
fn dialog_padding_and_action_gaps_are_the_tokens() {
let geometry = DialogGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.radius, 28.0, "7.1 CornerExtraLarge");
assert_eq!(geometry.padding, 24.0, "7.3 DialogPadding");
assert_eq!(geometry.title_gap, 16.0, "7.3 TitlePadding bottom");
assert_eq!(geometry.body_gap, 24.0, "7.3 TextPadding bottom");
assert_eq!(geometry.action_gap, 8.0, "7.6 ButtonsMainAxisSpacing");
assert_eq!(geometry.action_cross, 12.0, "7.6 ButtonsCrossAxisSpacing");
}
/// 7.7 + D27:scrim = 黑@0.32 叠在 canvas 上之后的**不透明**实色。
#[test]
fn scrim_is_scrimalpha_precomposited_onto_the_canvas() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let ui = ui(flavor);
let scheme = Scheme::baseline(flavor);
let canvas = scheme.get(MaterialLayer::Canvas.surface_role());
let expected = to_iced(scheme.scrim().over(canvas));
assert_eq!(
scrim(&ui),
expected,
"{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas」"
);
assert_eq!(
scrim(&ui).a,
1.0,
"D27:CPU 档不出现半透明(预合成后必须不透明)"
);
assert_ne!(
scrim(&ui),
ui.layer(MaterialLayer::Canvas),
"遮罩必须与画布有可见差异"
);
assert!((SCRIM_ALPHA - 0.32).abs() < f32::EPSILON, "令牌口径 α=0.32");
}
}
// ---- §15 空态(项目自定) ----------------------------------------------------
/// 15.1:图标块 96 / 圆角 16 / 图标 32(**全部 `[估]`**,官方没有这个组件)。
#[test]
fn empty_state_geometry_is_the_project_defined_set() {
let geometry = EmptyStateGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.block, 96.0);
assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large");
assert_eq!(geometry.icon, 32.0, "IconSize::Empty");
// **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。
// 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」
// 完全藏得住(渲染实测 264 vs 登记 232)。
assert_eq!(geometry.gap_title, 16.0, "块↔标题 = Space::X4");
assert_eq!(geometry.gap_body, 8.0, "标题↔副文案 = Space::X2");
assert_eq!(geometry.gap_action, 24.0, "副文案↔CTA = Space::X6");
assert_eq!(geometry.block, 96.0);
assert_eq!(
super::empty_state::BLOCK_LAYER,
MaterialLayer::Overlay,
"块底色是 SurfaceContainerHigh(材质层 Overlay)"
);
}
// ---- 结构 / 角色 ------------------------------------------------------------
/// 五个入口都能在**任意**消息类型下构建(契约 §2:不 import `app::Message`)。
#[test]
fn every_component_builds_for_a_foreign_message_type() {
#[derive(Clone, Debug, PartialEq)]
enum Other {
Yes,
No,
}
let ui = ui(SchemeFlavor::Light);
let _: Element<'_, Other> = linear(&ui, Progress::determinate(0.5));
let _: Element<'_, Other> = circular(&ui, Progress::indeterminate(0.3));
let _: Element<'_, Other> = snackbar(
&ui,
Snackbar::new("已保存")
.placement(Placement::Top)
.two_lines()
.action("撤销", Other::No)
.dismiss(Other::No),
);
let _: Element<'_, Other> = dialog(
&ui,
Dialog::new("确认删除?", "此操作不可撤销。")
.double(("取消", Other::No), ("删除", Other::Yes)),
);
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Instances,
"还没有实例",
"建一个开始游戏",
)
.action("新建", Other::Yes),
);
// 无动作的三种形态也要能构建(DialogActions::None / 无 action 的 Snackbar)
let _: Element<'_, Other> = dialog(&ui, Dialog::new("已是最新版本", "没有可用更新。"));
let _: Element<'_, Other> = snackbar(&ui, Snackbar::new("已复制"));
let _: Element<'_, Other> = empty_state(
&ui,
EmptyState::new(
unlucky_design::icons::IconKind::Logs,
"没有日志",
"启动一次游戏后这里会有内容",
),
);
}
/// 状态层必须来自 `interaction`(不是组件里自己乘 α):hover 层不透明且与容器色不同。
#[test]
fn snackbar_state_layers_come_from_the_interaction_module() {
let ui = ui(SchemeFlavor::Light);
let hover = interaction::overlay_color(
&ui,
Interaction::Hovered,
Role::InversePrimary,
Role::InverseSurface,
)
.expect("hover 有状态层");
assert_eq!(hover.a, 1.0, "状态层必须是实色(CPU/GPU 一致)");
assert_ne!(
hover,
ui.color(Role::InverseSurface),
"叠了 8% 必须看得出来"
);
// 禁用内容走 token 口径(38%),不是「变灰一点」
let disabled = interaction::disabled_content_over_role(&ui, Role::OnSurface, false);
assert_ne!(disabled, ui.color(Role::OnSurface));
assert_eq!(disabled.a, 1.0);
}