//! 组件目录的自检门禁(`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) { 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 { 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 { 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::() == 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("), "判据写坏了" ); } }