Files
Unlucky-Launcher/crates/app-iced/tests/no_hardcoded_tokens.rs
T
AskaEth a1adf024ba fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。

M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX

P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
  视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill

视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
  (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)

视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
  只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中

视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
  现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)

P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)

顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)

门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
2026-11-01 17:46:55 +08:00

352 lines
13 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.
//! 门禁:**组件层零硬编码**(色值 / 尺寸)与「不依赖 slint」。
//!
//! 这三条测试会扫本 crate 的 `src/**` 与 `Cargo.toml`,故意写得「宁可错杀」:
//! 将来 reviewer 复跑 `cargo test -p unlucky-app-iced` 就能挡住「顺手写个 #RRGGBB」的提交。
//!
//! 判定口径(与 `component-fidelity-rubric.md` T1/T2、`visual-acceptance-protocol.md` §1 对齐):
//!
//! - **色值字面量**:`#` + 6 位十六进制(且**至少含一个数字**,这样文档里的 `#RRGGBB` 占位不会被误杀)
//! 或 `Color::from_rgb(0.1, …)` / `color!(0x…)` 这类带数值参数的构造。
//! - **令牌优先**:唯一允许的是 `theme.rs`(适配层),且它也只做 `token → iced` 的搬运。
//! - **尺寸字面量**:`shell.rs` 里 `.width(12.0)` / `.height(34)` / `.size(22)` 一律不许出现
//! (必须走 `LayoutMetrics` / `TypeStyle` / `Space`)。
use std::path::{Path, PathBuf};
/// 本 crate 根目录(编译期注入,换机器也成立)。
fn manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
/// 递归收集 `dir` 下的 `.rs` 文件。
fn rust_files(dir: &Path, collected: &mut Vec<PathBuf>) {
let entries =
std::fs::read_dir(dir).unwrap_or_else(|error| panic!("读不动 {}:{error}", dir.display()));
for entry in entries {
let path = entry.expect("目录项读失败").path();
if path.is_dir() {
rust_files(&path, collected);
} else if path.extension().is_some_and(|ext| ext == "rs") {
collected.push(path);
}
}
}
fn sources() -> Vec<(PathBuf, String)> {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src"), &mut files);
assert!(
files.len() >= 8,
"只扫到 {} 个源文件:扫描逻辑坏了,这条门禁等于没跑",
files.len()
);
files
.into_iter()
.map(|path| {
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("读不动 {}:{error}", path.display()));
(path, text)
})
.collect()
}
/// `#RRGGBB` / `#AARRGGBB` 形式的色值字面量(至少含一个数字,避开 `#RRGGBB` 这种占位写法)。
fn hex_color_at(bytes: &[u8], start: usize) -> Option<&str> {
let digits = bytes.get(start + 1..start + 7)?;
let all_hex = digits
.iter()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_whitespace());
let has_number = digits.iter().any(u8::is_ascii_digit);
if all_hex && has_number {
// 后面不能再跟着十六进制字符(否则是更长的 token,不是 6 位色值)
let next = bytes.get(start + 7).copied();
if next.is_none_or(|byte| !byte.is_ascii_hexdigit()) {
return Some("hex 色值字面量(#RRGGBB)");
}
}
None
}
/// 带数值参数的取色构造(`Color::from_rgb(0.1, …)`、`Color::from_rgb8(103, 80, 164)`、`color!(0x12, …)`)。
///
/// **子串匹配要注意前后缀**:`from_rgb(` 匹配不到 `from_rgb8(`,所以 8 bit 那个必须单独列出来
/// (reviewer 造样就是这么绕过去的)。
const NUMERIC_COLOR_CALLS: [&str; 8] = [
"Color::from_rgb(",
"Color::from_rgb8(",
"Color::from_rgba(",
"Color::from_rgba8(",
"Color::from_hex(",
"Color::rgba(",
"Color::rgb8(",
"color!(",
];
/// 取色构造后面是不是跟着数值字面量(允许 `Color::from_rgba8(color.r, …)` 这类搬运)。
fn numeric_color_call(text: &str) -> Option<&'static str> {
for pattern in NUMERIC_COLOR_CALLS {
let mut from = 0;
while let Some(found) = text[from..].find(pattern) {
let after = from + found + pattern.len();
let next = text[after..].trim_start().chars().next();
// 参数以数字或 `0x`/`#` 开头才算字面量;`Color::from_rgba8(color.r, …)` 是搬运,放行。
if next.is_some_and(|ch| ch.is_ascii_digit() || ch == '#') {
return Some(pattern);
}
from = after;
}
}
None
}
/// 结构体字面量取色:`Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }`。
///
/// 判据:`Color {` 之后 64 个字符内同时出现 `r:` 与 `g:` 且有数字(`Color { a: 0.0, .. }` 这种
/// 只改 alpha 的写法不会命中,因为它没有 `r:`)。
fn color_struct_literal(text: &str) -> bool {
let mut from = 0;
while let Some(found) = text[from..].find("Color {") {
let after = from + found + "Color {".len();
let window: String = text[after..].chars().take(64).collect();
let has_channels = window.contains("r:") && window.contains("g:");
let has_number = window.chars().any(|ch| ch.is_ascii_digit());
if has_channels && has_number {
return true;
}
from = after;
}
false
}
#[test]
fn no_hardcoded_colors_in_sources() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
let bytes = line.as_bytes();
let mut position = 0;
while let Some(found) = line[position..].find('#') {
let at = position + found;
if let Some(kind) = hex_color_at(bytes, at) {
offenders.push(format!(
"{}:{} {line:?} -> {kind}",
path.display(),
index + 1
));
}
position = at + 1;
}
if let Some(pattern) = numeric_color_call(line) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern}",
path.display(),
index + 1
));
}
if color_struct_literal(line) {
offenders.push(format!(
"{}:{} {line:?} -> Color {{ r: … }} 结构体字面量",
path.display(),
index + 1
));
}
}
}
assert!(
offenders.is_empty(),
"组件层不许出现色值字面量(一律走 unlucky_design 的 Role):\n{}",
offenders.join("\n")
);
}
/// 判断某个布局调用后面是不是直接跟了尺寸字面量;`0`(= 无内边距)放行。
///
/// iced 里尺寸经常被包一层:`.width(Length::Fixed(123.0))`、`.padding(Padding::from([12.0, 8.0]))`、
/// `.size(Size::new(10.0, 10.0))` —— 所以遇到这些包装要**往里剥一层**再判。
fn size_literal(argument: &str) -> Option<String> {
let mut rest = argument.trim_start();
for wrapper in ["Length::Fixed(", "Padding::from(", "Size::new("] {
if let Some(after) = rest.strip_prefix(wrapper) {
rest = after.trim_start().trim_start_matches(['[', '(']);
break;
}
}
let number: String = rest
.trim_start()
.chars()
.take_while(|ch| ch.is_ascii_digit() || *ch == '.')
.collect();
if number.is_empty() || number.trim_end_matches('.').parse::<f64>() == Ok(0.0) {
return None;
}
Some(number)
}
#[test]
fn shell_takes_every_size_from_tokens() {
let mut files = Vec::new();
rust_files(&manifest_dir().join("src").join("shell"), &mut files);
assert!(
files.len() >= 2,
"shell 模块至少有两个文件(mod.rs + nav.rs),实际扫到 {}",
files.len()
);
let mut offenders = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).expect("读 shell 源文件");
for (index, line) in text.lines().enumerate() {
// 文档注释是散文不是代码:里面写 `.width(24)` 是在解释坑,不该判违规
if line.trim_start().starts_with("//") {
continue;
}
for pattern in [".width(", ".height(", ".size(", ".spacing(", ".padding("] {
let mut from = 0;
while let Some(found) = line[from..].find(pattern) {
let after = from + found + pattern.len();
if let Some(number) = size_literal(&line[after..]) {
offenders.push(format!(
"{}:{} {line:?} -> {pattern} 后面直接写了数字 {number}",
path.display(),
index + 1
));
}
from = after;
}
}
}
}
assert!(
offenders.is_empty(),
"骨架尺寸必须来自 LayoutMetrics / TypeStyle / Space:\n{}",
offenders.join("\n")
);
}
/// 依赖行里出现 `slint` 就算违规(注释行放过:说明并存期要提到 slint)。
fn manifest_has_slint_dependency(manifest: &str) -> Vec<String> {
manifest
.lines()
.enumerate()
.filter(|(_, line)| {
let trimmed = line.trim_start();
!trimmed.starts_with('#') && trimmed.contains("slint")
})
.map(|(index, line)| format!("Cargo.toml:{} {line}", index + 1))
.collect()
}
#[test]
fn this_crate_never_depends_on_slint() {
let manifest =
std::fs::read_to_string(manifest_dir().join("Cargo.toml")).expect("读 Cargo.toml");
let offenders = manifest_has_slint_dependency(&manifest);
assert!(
offenders.is_empty(),
"P4 的 iced 版 crate 不许依赖 slint(D26):\n{}",
offenders.join("\n")
);
for (path, text) in sources() {
for (index, line) in text.lines().enumerate() {
// 只查真代码里的 use/路径,文档里提 slint 是允许的(要解释并存期)
let code = line.trim_start();
let is_import = code.starts_with("use ") || code.starts_with("extern crate");
if is_import && code.contains("slint") {
panic!("{}:{} 引入了 slint:{line}", path.display(), index + 1);
}
}
}
}
#[test]
fn the_gate_itself_catches_a_planted_violation() {
// 自检:把违规样本喂给同一套判定,必须被抓住(否则门禁是空转)。
// **reviewer r1 造样验证过的 5 个漏网样本逐条钉在这里**,退化成「放行」立刻红。
// 1. `Color::from_rgb8(103, 80, 164)` —— 8bit 版曾因 `from_rgb(` 匹配不到而漏
assert!(numeric_color_call("Color::from_rgb8(103, 80, 164)").is_some());
// 2. `iced::Color::from_rgb8(...)` —— 带路径前缀的写法同样要拦
assert!(numeric_color_call("iced::Color::from_rgb8(103, 80, 164)").is_some());
// 3. `Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }` —— 结构体字面量
assert!(color_struct_literal(
"Color { r: 0.4, g: 0.3, b: 0.6, a: 1.0 }"
));
// 4. `.width(Length::Fixed(123.0))` —— 尺寸被 iced 包装一层
assert_eq!(
size_literal("Length::Fixed(123.0))").as_deref(),
Some("123.0")
);
// 5. `.padding(Padding::from([12.0, 8.0]))` —— 数组字面量
assert_eq!(
size_literal("Padding::from([12.0, 8.0]))").as_deref(),
Some("12.0")
);
// 原有规则不能退化
assert!(hex_color_at(b" color: #6750A4,", 11).is_some());
assert!(hex_color_at(b"// placeholder #RRGGBB here", 15).is_none());
assert!(numeric_color_call("Color::from_rgb(0.1, 0.2, 0.3)").is_some());
assert!(numeric_color_call("Color::from_rgba8(color.r, color.g, color.b, color.a)").is_none());
assert!(numeric_color_call("color!(0x5865F2)").is_some());
assert!(size_literal("40.0)").is_some());
assert!(size_literal("0)").is_none());
assert!(size_literal("metrics.titlebar_height)").is_none());
assert!(size_literal("Length::Fixed(thickness))").is_none());
assert!(size_literal("Padding::from([0.0, ui.space(Space::X4)]))").is_none());
assert!(!color_struct_literal(
"Color::from_rgba8(color.r, color.g, color.b, 1.0)"
));
assert!(!color_struct_literal("let color = Color { a: 0.5 };"));
assert!(!manifest_has_slint_dependency("slint = \"1.18\"").is_empty());
assert!(manifest_has_slint_dependency("# 不得依赖 slint").is_empty());
}
/// 渲染层回归守门(视觉复审 P2-1 的教训):`indicator_matches_the_reference_56x32` 只测纯函数,
/// 门禁全绿但图上的药丸实际是 88×32 —— 因为 `.center_x(Length::Fill)` 会把容器 width 覆盖成 Fill。
/// 这里直接扫 `nav.rs`:药丸容器**不许**出现 `center_x(` / `center_y(`。
#[test]
fn pill_never_overrides_its_size_with_fill() {
let nav = manifest_dir().join("src").join("shell").join("nav.rs");
let text = std::fs::read_to_string(&nav).expect("读 nav.rs");
for (index, line) in text.lines().enumerate() {
let code = line.trim_start();
if code.starts_with("//") {
continue;
}
for pattern in ["center_x(", "center_y("] {
assert!(
!code.contains(pattern),
"nav.rs:{} 用了 {pattern}——`center_*(Length::Fill)` 会把容器尺寸改成 Fill, 药丸会被父级宽度覆盖(视觉复审 P2-1)。居中请用父级 `align_x`:{line}",
index + 1
);
}
}
}