WIP(app-iced): M75 组件基座 checkpoint —— 组件骨架/按钮五式/分隔线/gallery harness

已落地:
- components/{mod,interaction,layout,divider,gate}.rs + buttons/{mod,geometry,assemble,tests}.rs
- gallery/{mod,buttons,probe,scan}.rs + 四个家族桩(selection/inputs/feedback/navigation)
- cli.rs: --screen=shell|gallery + capture_flavor(截图默认 Light,删掉等系统主题的闸门)
- app.rs: gallery 路由 + DemoMessage 接线(400 行)

待办(下一个 commit):编译/测试全绿、浅深矩阵出图、1:1 并排与数值表。
This commit is contained in:
AskaEth committed 2026-11-02 04:50:44 +08:00
1 parent 22ddc74784
commit 4774e7b788
23 files changed
+3136 -133

No files matched your search

+294
View File
@@ -0,0 +1,294 @@
//! 组件目录的自检门禁(`src/components/**`)。
//!
//! 为什么要有这份:`tests/no_hardcoded_tokens.rs` 只扫**色值**和 `shell/` 的尺寸字面量,
//! 组件库目录是后来才开的——如果不管,五条并行线会各自写回 `.width(24.0)` 与
//! `center_x(Length::Fill)`,M68 那两个形态级错误就会被复制五份。
//!
//! 四条判据(每条对应一个已发生过的错误):
//!
//! | 判据 | 抓的是什么 | 历史 |
//! |---|---|---|
//! | Q8:禁 `center_x(` / `center_y(` / `.center(` | 容器尺寸被 `Fill` 静默覆盖 | 药丸 56×32 → 88×32 |
//! | Q9:图标必须经 `layout::fixed` / `centered_fixed` | 字形被 Button 的 Fill 撑满 | 24 的字形渲染成 46px |
//! | T2:禁尺寸字面量 | 组件里重复定义令牌值 | rubric T2 |
//! | 400 行 | 单文件上限(AGENTS.md §3-1) | `guards.yml` 的 file-size job |
//!
//! 扫描对象是**源码文本**而不是布局结果——这不是偷懒:布局结果要真窗口(CI 无桌面),
//! 而「把尺寸交给 `center_*`」这个错误 100% 出现在源码形态里(M68 就是这样被抓的)。
//! 像素级几何另有 `gallery::probe` 对着真截图量。
use std::path::{Path, PathBuf};
/// 单文件行数上限(AGENTS.md §3-1)。
const MAX_LINES: usize = 400;
fn components_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("components")
}
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);
}
}
}
/// 全部组件源文件(`mod.rs` 也要扫:它是共享地基,最容易混进字面量)。
fn sources() -> Vec<(PathBuf, String)> {
let mut files = Vec::new();
rust_files(&components_dir(), &mut files);
files.sort();
assert!(
files.len() >= 15,
"只扫到 {} 个组件源文件:扫描逻辑坏了,门禁等于没跑",
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()
}
/// 去掉 `#[cfg(test)]` 之后的全部内容:测试里写参考值是**应该的**(12b 的 40/24/20
/// 必须在测试里以字面量出现,否则测不出偏离)。
fn product_part(text: &str) -> &str {
match text.find("#[cfg(test)]") {
Some(at) => &text[..at],
None => text,
}
}
/// 代码行(跳过注释行:文档里写 `.center_x(` 是在解释坑,不该判违规)。
fn code_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
text.lines().enumerate().filter_map(|(index, line)| {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
None
} else {
Some((index + 1, line))
}
})
}
#[test]
fn q8_no_center_shortcuts_that_swallow_fixed_sizes() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (line_no, line) in code_lines(product_part(&text)) {
for pattern in ["center_x(", "center_y(", ".center("] {
if line.contains(pattern) {
offenders.push(format!(
"{}:{line_no} {line}\n → {pattern} 会把容器尺寸改成 Fill(Q8),\
居中请用父级 align_x 或 layout::centered_fixed",
path.display()
));
}
}
}
}
assert!(offenders.is_empty(), "Q8 违规:\n{}", offenders.join("\n"));
}
#[test]
fn q9_icons_never_reach_a_button_unwrapped() {
let mut offenders = Vec::new();
for (path, text) in sources() {
let product = product_part(&text);
// 允许两种写法:同一行里已经包在 layout:: 里;或它是 `layout::fixed(` 多行调用的实参
// (图标常作为第 3 个参数出现在后两行,所以往回看 4 行)。
let mut window: Vec<&str> = Vec::new();
for (line_no, line) in code_lines(product) {
if line.contains("icons::view") {
let wrapped =
line.contains("layout::fixed") || line.contains("layout::centered_fixed");
let continued = window.iter().rev().any(|prev| {
prev.contains("layout::fixed") || prev.contains("layout::centered_fixed")
});
if !wrapped && !continued {
offenders.push(format!(
"{}:{line_no} {line}\n → 图标必须经 layout::fixed / centered_fixed 包一层(Q9)",
path.display()
));
}
}
if !line.trim().is_empty() {
window.push(line);
if window.len() > 4 {
window.remove(0);
}
}
}
}
assert!(offenders.is_empty(), "Q9 违规:\n{}", offenders.join("\n"));
}
/// 布局调用后面直接跟数字 → 判违规(`0` 与 `0.0` 放行: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 t2_no_size_literals_in_components() {
let mut offenders = Vec::new();
for (path, text) in sources() {
for (line_no, line) in code_lines(product_part(&text)) {
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_no} {line} -> {pattern} 后面写了数字 {number}",
path.display()
));
}
from = after;
}
}
}
}
assert!(
offenders.is_empty(),
"组件里不许有尺寸字面量(走 LayoutMetrics / Space / ComponentMetrics):\n{}",
offenders.join("\n")
);
}
#[test]
fn every_component_file_stays_under_the_line_limit() {
let mut offenders = Vec::new();
for (path, text) in sources() {
let lines = text.lines().count();
if lines > MAX_LINES {
offenders.push(format!(
"{}: {lines} 行(上限 {MAX_LINES})",
path.display()
));
}
}
assert!(
offenders.is_empty(),
"单文件 ≤400 行(AGENTS.md §3-1,guards.yml 会判红):\n{}",
offenders.join("\n")
);
}
/// 四个家族桩必须**只有桩**(不许顺手把别人的组件写了)。
///
/// 判据:文件里 `pub fn` 只允许出现一次,且它是 `demo`。
#[test]
fn the_four_family_stubs_are_still_stubs() {
for family in ["selection", "inputs", "feedback", "navigation"] {
let path = components_dir().join(family).join("mod.rs");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("家族桩 {} 不见了:{error}", path.display()));
let product = product_part(&text);
let pub_fns: Vec<&str> = product
.lines()
.filter(|line| line.trim_start().starts_with("pub fn "))
.collect();
assert_eq!(
pub_fns.len(),
1,
"{} 只该有占位 demo(),实际有 {} 个 pub fn:{pub_fns:?}(别顺手实现别人的家族)",
path.display(),
pub_fns.len()
);
assert!(
pub_fns[0].contains("fn demo"),
"{} 里唯一那个 pub fn 应该是 demo,实际是 {:?}",
path.display(),
pub_fns[0]
);
}
}
/// gallery 的五个家族文件必须都在(`mod.rs` 声明了它们,缺一个就编译不过——
/// 这条在并行线删文件/改名时给出人话,而不是一堆 `unresolved module`)。
#[test]
fn gallery_keeps_one_file_per_family() {
for family in ["buttons", "selection", "inputs", "feedback", "navigation"] {
let path = components_dir()
.join("gallery")
.join(format!("{family}.rs"));
assert!(
path.is_file(),
"gallery 缺 {}(本文件结构是并行地基)",
path.display()
);
let text = std::fs::read_to_string(&path).expect("读 gallery 家族文件");
assert!(
text.contains("fn section"),
"{} 必须提供 section()(gallery/mod.rs 调它)",
path.display()
);
}
}
/// 门禁自身不能空转:造几个违规样本喂给判据。
#[test]
fn the_gate_catches_planted_violations() {
assert_eq!(
size_literal("Length::Fixed(40.0))").as_deref(),
Some("40.0")
);
assert_eq!(size_literal("0)").as_deref(), None);
assert_eq!(
size_literal("ui.metrics().control_height)").as_deref(),
None
);
assert!(product_part("a\n#[cfg(test)]\n.width(40.0)\n")
.find(".width(40.0)")
.is_none());
assert!(product_part("a\n.width(40.0)\n")
.find(".width(40.0)")
.is_some());
// Q8 判据本身
for code in ["x.center_x(Length::Fill)", "x.center(Length::Fill)"] {
assert!(
code.contains("center_x(") || code.contains(".center("),
"判据写坏了"
);
}
}