|
| 1 | +//! 语言包不变量门禁(C2006) |
| 2 | +//! |
| 3 | +//! `ui/` 是纯静态前端,此前**没有任何自动化测试覆盖**:语言包的中英键集一致性、 |
| 4 | +//! `data-i18n*` 属性的可解析性、`T("…")` 字面量的可解析性,全都只能靠人工比对。 |
| 5 | +//! 本模块把这三类不变量固化为 `cargo test` 的一部分,让「漏键 / 拼错键 / 只改了一种语言」 |
| 6 | +//! 在 CI 上直接变红,而不是上线后由用户或截图发现界面在显示原始键名(如 `admin.emp.col.member`)。 |
| 7 | +//! |
| 8 | +//! 设计约束: |
| 9 | +//! - **仅测试期编译**(`#[cfg(test)] mod`,见 `main.rs`),不进生产二进制; |
| 10 | +//! - **不引入依赖**:仓库没有 `ui/package.json`,无法借用 JS 工具链, |
| 11 | +//! 因此在 Rust 侧手写等价的静态扫描(原因见 `scan_object_keys` 的注释); |
| 12 | +//! - **只做静态扫描,不执行 JS**; |
| 13 | +//! - `ZH_KEY_COUNT` 等常量是**阳性对照**而非装饰,理由见 `packs()`。 |
| 14 | +
|
| 15 | +use std::collections::{BTreeSet, HashSet}; |
| 16 | + |
| 17 | +/// 前端源文件在**编译期**读入:测试不依赖工作目录与文件系统布局。 |
| 18 | +const I18N_JS: &str = include_str!("../ui/js/i18n.js"); |
| 19 | +const INDEX_HTML: &str = include_str!("../ui/index.html"); |
| 20 | +const APP_JS: &str = include_str!("../ui/js/app.js"); |
| 21 | + |
| 22 | +/// 语言包区段的起止标记。 |
| 23 | +/// |
| 24 | +/// ⚠️ 终点必须取**对象字面量自身的收尾** `"\n };"`,不能取后面的 `window.I18N`: |
| 25 | +/// 后者位于字面量之后数千字符处(中间是运行期代码),会让区段越界扫进代码区, |
| 26 | +/// 把三元表达式 `current === "en" ? "en" : "zh-CN"` 里的 `"en" :` 误当对象键, |
| 27 | +/// 从而凭空多出一个键 `en`(C2005 坑 69)。 |
| 28 | +const ZH_START: &str = "var ZH = {"; |
| 29 | +const EN_START: &str = "var EN = {"; |
| 30 | +const EN_END: &str = "\n };"; |
| 31 | + |
| 32 | +/// 阳性对照真值:**改动语言包/前端文案时应刻意更新这些数字**。 |
| 33 | +/// 它们的作用是把「提取器静默失真」与「语言包真的变了」区分开(见 `packs()`)。 |
| 34 | +const ZH_KEY_COUNT: usize = 775; |
| 35 | +const EN_KEY_COUNT: usize = 775; |
| 36 | +const STATIC_ATTR_COUNT: usize = 330; |
| 37 | +const STATIC_ATTR_DISTINCT: usize = 305; |
| 38 | +const T_LITERAL_COUNT: usize = 520; |
| 39 | +const T_LITERAL_DISTINCT: usize = 418; |
| 40 | + |
| 41 | +/// 切出语言包区段(起点标记 → 终点标记,含起点)。 |
| 42 | +fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { |
| 43 | + let s = src |
| 44 | + .find(start_mark) |
| 45 | + .unwrap_or_else(|| panic!("语言包起点标记 `{start_mark}` 未找到 —— 语言包结构变了?")); |
| 46 | + let rest = &src[s..]; |
| 47 | + let e = rest |
| 48 | + .find(end_mark) |
| 49 | + .unwrap_or_else(|| panic!("语言包终点标记 `{end_mark}` 未找到 —— 语言包结构变了?")); |
| 50 | + &rest[..e] |
| 51 | +} |
| 52 | + |
| 53 | +/// 扫描「字符串字面量 + 可选空白 + `:`」形态,返回其中被当作对象键的字面量。 |
| 54 | +/// |
| 55 | +/// 这里用逐字节扫描而不是正则,有两个硬理由: |
| 56 | +/// 1. 仓库**没有正则依赖**(Cargo.toml 里没有 `regex`),不值得为一个测试引入; |
| 57 | +/// 2. 即便有,**行首锚定的正则也会漏键** —— `share.day.1`…`.6` 被挤在同一行, |
| 58 | +/// 按行匹配只能取到第一个,包大小会从 775 静默变成 770。 |
| 59 | +/// 一个「少数」的门禁比没有门禁更危险:它会让人以为查过了。 |
| 60 | +fn scan_object_keys(region: &str) -> Vec<String> { |
| 61 | + let b = region.as_bytes(); |
| 62 | + let mut out = Vec::new(); |
| 63 | + let mut i = 0; |
| 64 | + while i < b.len() { |
| 65 | + let c = b[i]; |
| 66 | + if c == b'"' || c == b'\'' { |
| 67 | + let quote = c; |
| 68 | + let start = i + 1; |
| 69 | + let mut j = start; |
| 70 | + while j < b.len() && b[j] != quote { |
| 71 | + if b[j] == b'\\' { |
| 72 | + j += 1; // 跳过被转义的字符 |
| 73 | + } |
| 74 | + j += 1; |
| 75 | + } |
| 76 | + if j >= b.len() { |
| 77 | + break; // 引号未闭合:语料异常,交给上层断言处理 |
| 78 | + } |
| 79 | + let mut k = j + 1; |
| 80 | + while k < b.len() && matches!(b[k], b' ' | b'\t' | b'\n' | b'\r') { |
| 81 | + k += 1; |
| 82 | + } |
| 83 | + if k < b.len() && b[k] == b':' { |
| 84 | + // 键名本身是 ASCII,因此 start/j 必在字符边界上 |
| 85 | + out.push(region[start..j].to_string()); |
| 86 | + } |
| 87 | + i = j + 1; |
| 88 | + } else { |
| 89 | + i += 1; |
| 90 | + } |
| 91 | + } |
| 92 | + out |
| 93 | +} |
| 94 | + |
| 95 | +/// 扫描 `data-i18n` / `data-i18n-ph` / `data-i18n-title` / `data-i18n-label` 属性的取值。 |
| 96 | +fn scan_static_attributes(src: &str) -> Vec<String> { |
| 97 | + let b = src.as_bytes(); |
| 98 | + let needle = b"data-i18n"; |
| 99 | + let mut out = Vec::new(); |
| 100 | + let mut i = 0; |
| 101 | + while i + needle.len() <= b.len() { |
| 102 | + if b[i..].starts_with(needle) { |
| 103 | + // 命中前缀后继续走完可能的后缀(-ph / -title / -label) |
| 104 | + let mut j = i + needle.len(); |
| 105 | + while j < b.len() && !matches!(b[j], b'=' | b' ' | b'\t' | b'\n' | b'\r' | b'>') { |
| 106 | + j += 1; |
| 107 | + } |
| 108 | + if j < b.len() && b[j] == b'=' { |
| 109 | + let mut k = j + 1; |
| 110 | + while k < b.len() && matches!(b[k], b' ' | b'\t') { |
| 111 | + k += 1; |
| 112 | + } |
| 113 | + if k < b.len() && (b[k] == b'"' || b[k] == b'\'') { |
| 114 | + let quote = b[k]; |
| 115 | + let start = k + 1; |
| 116 | + let mut m = start; |
| 117 | + while m < b.len() && b[m] != quote { |
| 118 | + m += 1; |
| 119 | + } |
| 120 | + if m < b.len() { |
| 121 | + out.push(src[start..m].to_string()); |
| 122 | + } |
| 123 | + i = m + 1; |
| 124 | + continue; |
| 125 | + } |
| 126 | + } |
| 127 | + i = j; |
| 128 | + } else { |
| 129 | + i += 1; |
| 130 | + } |
| 131 | + } |
| 132 | + out |
| 133 | +} |
| 134 | + |
| 135 | +/// 扫描 `T("字面量")` 形态的文案键,返回其中的字面量。 |
| 136 | +/// |
| 137 | +/// 前置字符若是标识符字符或 `.` 则跳过,避免命中 `fmt(` / `obj.T(` 之类。 |
| 138 | +/// 动态拼接(`T("share.day." + n)`)**会被捕获**,随后由调用方按「以 `.` 结尾」排除 —— |
| 139 | +/// 这正是本仓今天唯一的特例(见 `every_t_literal_resolves`)。 |
| 140 | +fn scan_t_literals(src: &str) -> Vec<String> { |
| 141 | + let b = src.as_bytes(); |
| 142 | + let mut out = Vec::new(); |
| 143 | + let mut i = 0; |
| 144 | + while i + 2 <= b.len() { |
| 145 | + if b[i] == b'T' && b[i + 1] == b'(' { |
| 146 | + let prev_ok = i == 0 |
| 147 | + || !(b[i - 1].is_ascii_alphanumeric() || b[i - 1] == b'_' || b[i - 1] == b'.'); |
| 148 | + if prev_ok { |
| 149 | + let mut k = i + 2; |
| 150 | + while k < b.len() && matches!(b[k], b' ' | b'\t' | b'\n' | b'\r') { |
| 151 | + k += 1; |
| 152 | + } |
| 153 | + if k < b.len() && (b[k] == b'"' || b[k] == b'\'') { |
| 154 | + let quote = b[k]; |
| 155 | + let start = k + 1; |
| 156 | + let mut m = start; |
| 157 | + while m < b.len() && b[m] != quote { |
| 158 | + if b[m] == b'\\' { |
| 159 | + m += 1; |
| 160 | + } |
| 161 | + m += 1; |
| 162 | + } |
| 163 | + if m < b.len() { |
| 164 | + out.push(src[start..m].to_string()); |
| 165 | + } |
| 166 | + i = m + 1; |
| 167 | + continue; |
| 168 | + } |
| 169 | + } |
| 170 | + i += 1; |
| 171 | + } else { |
| 172 | + i += 1; |
| 173 | + } |
| 174 | + } |
| 175 | + out |
| 176 | +} |
| 177 | + |
| 178 | +/// 收集既不在中文包、也不在英文包中的键(即会被原样显示给用户的键名)。 |
| 179 | +fn unresolved<'a>( |
| 180 | + keys: impl IntoIterator<Item = &'a String>, |
| 181 | + zh: &BTreeSet<String>, |
| 182 | + en: &BTreeSet<String>, |
| 183 | +) -> Vec<String> { |
| 184 | + keys.into_iter() |
| 185 | + .filter(|k| !zh.contains(*k) || !en.contains(*k)) |
| 186 | + .cloned() |
| 187 | + .collect() |
| 188 | +} |
| 189 | + |
| 190 | +/// 读出两个语言包,并做阳性对照校验。 |
| 191 | +/// |
| 192 | +/// ⚠️ 计数断言是**阳性对照**,不是装饰:C2005 曾把区段标记写错,得到两个**空集**, |
| 193 | +/// 于是「中英键集一致」在空集上「通过」—— 假绿的门禁比没有门禁更危险。 |
| 194 | +/// 因此这里一旦与已知真值不符就**直接 panic**,绝不带着空集或少数集继续断言。 |
| 195 | +fn packs() -> (BTreeSet<String>, BTreeSet<String>) { |
| 196 | + let zh_raw = scan_object_keys(pack_region(I18N_JS, ZH_START, EN_START)); |
| 197 | + let en_raw = scan_object_keys(pack_region(I18N_JS, EN_START, EN_END)); |
| 198 | + assert_eq!( |
| 199 | + zh_raw.len(), |
| 200 | + ZH_KEY_COUNT, |
| 201 | + "中文包键数应为 {ZH_KEY_COUNT},实得 {} —— 提取器已失真(区段标记或扫描规则需重核),拒绝继续", |
| 202 | + zh_raw.len() |
| 203 | + ); |
| 204 | + assert_eq!( |
| 205 | + en_raw.len(), |
| 206 | + EN_KEY_COUNT, |
| 207 | + "英文包键数应为 {EN_KEY_COUNT},实得 {} —— 提取器已失真,拒绝继续", |
| 208 | + en_raw.len() |
| 209 | + ); |
| 210 | + (zh_raw.into_iter().collect(), en_raw.into_iter().collect()) |
| 211 | +} |
| 212 | + |
| 213 | +/// 语言包不变量测试。 |
| 214 | +/// |
| 215 | +/// 整个模块只在测试期编译(`main.rs` 里是 `#[cfg(test)] mod i18n_pack`), |
| 216 | +/// 因此 `include_str!` 嵌进来的前端源码**不会**进入发布二进制。 |
| 217 | +#[cfg(test)] |
| 218 | +mod tests { |
| 219 | + use super::*; |
| 220 | + |
| 221 | + #[test] |
| 222 | + fn lang_pack_keys_are_identical() { |
| 223 | + let (zh, en) = packs(); |
| 224 | + let only_zh: Vec<&String> = zh.difference(&en).collect(); |
| 225 | + let only_en: Vec<&String> = en.difference(&zh).collect(); |
| 226 | + assert!( |
| 227 | + only_zh.is_empty() && only_en.is_empty(), |
| 228 | + "中英语言包键集不一致:仅中文有 {only_zh:?};仅英文有 {only_en:?}" |
| 229 | + ); |
| 230 | + } |
| 231 | + |
| 232 | + #[test] |
| 233 | + fn lang_pack_has_no_duplicate_keys() { |
| 234 | + for (name, region) in [ |
| 235 | + ("zh", pack_region(I18N_JS, ZH_START, EN_START)), |
| 236 | + ("en", pack_region(I18N_JS, EN_START, EN_END)), |
| 237 | + ] { |
| 238 | + let keys = scan_object_keys(region); |
| 239 | + assert!(!keys.is_empty(), "{name} 包未扫到任何键 —— 提取器已失真"); |
| 240 | + let mut seen = HashSet::new(); |
| 241 | + let dups: Vec<String> = keys |
| 242 | + .into_iter() |
| 243 | + .filter(|k| !seen.insert(k.clone())) |
| 244 | + .collect(); |
| 245 | + assert!( |
| 246 | + dups.is_empty(), |
| 247 | + "{name} 包存在重复键(后定义者会静默覆盖前者):{dups:?}" |
| 248 | + ); |
| 249 | + } |
| 250 | + } |
| 251 | + |
| 252 | + #[test] |
| 253 | + fn every_static_i18n_attribute_resolves() { |
| 254 | + let (zh, en) = packs(); |
| 255 | + let raw = scan_static_attributes(INDEX_HTML); |
| 256 | + let distinct: BTreeSet<String> = raw.iter().cloned().collect(); |
| 257 | + assert_eq!( |
| 258 | + raw.len(), |
| 259 | + STATIC_ATTR_COUNT, |
| 260 | + "ui/index.html 的 data-i18n* 属性数应为 {STATIC_ATTR_COUNT},实得 {} —— 提取器已失真", |
| 261 | + raw.len() |
| 262 | + ); |
| 263 | + assert_eq!( |
| 264 | + distinct.len(), |
| 265 | + STATIC_ATTR_DISTINCT, |
| 266 | + "data-i18n* 去重后应为 {STATIC_ATTR_DISTINCT},实得 {}", |
| 267 | + distinct.len() |
| 268 | + ); |
| 269 | + let missing = unresolved(distinct.iter(), &zh, &en); |
| 270 | + assert!( |
| 271 | + missing.is_empty(), |
| 272 | + "以下 data-i18n* 属性在语言包中不存在(页面会原样显示键名):{missing:?}" |
| 273 | + ); |
| 274 | + } |
| 275 | + |
| 276 | + #[test] |
| 277 | + fn every_t_literal_resolves() { |
| 278 | + let (zh, en) = packs(); |
| 279 | + let raw = scan_t_literals(APP_JS); |
| 280 | + let distinct: BTreeSet<String> = raw.iter().cloned().collect(); |
| 281 | + assert_eq!( |
| 282 | + raw.len(), |
| 283 | + T_LITERAL_COUNT, |
| 284 | + "ui/js/app.js 的 T() 字面量数应为 {T_LITERAL_COUNT},实得 {} —— 提取器已失真", |
| 285 | + raw.len() |
| 286 | + ); |
| 287 | + assert_eq!( |
| 288 | + distinct.len(), |
| 289 | + T_LITERAL_DISTINCT, |
| 290 | + "T() 字面量去重后应为 {T_LITERAL_DISTINCT},实得 {}", |
| 291 | + distinct.len() |
| 292 | + ); |
| 293 | + // 以 `.` 结尾的是**动态拼接前缀**(今天只有 `"share.day."`,拼接出 share.day.1…7), |
| 294 | + // 它本身不是完整键,不能按缺键报错。 |
| 295 | + let checked: Vec<&String> = distinct.iter().filter(|k| !k.ends_with('.')).collect(); |
| 296 | + let missing = unresolved(checked, &zh, &en); |
| 297 | + assert!( |
| 298 | + missing.is_empty(), |
| 299 | + "以下 T() 字面量在语言包中不存在(界面会原样显示键名):{missing:?}" |
| 300 | + ); |
| 301 | + } |
| 302 | + |
| 303 | + /// 阴性对照:把「删键」「拼错键」注入语料,检查器必须真的报错。 |
| 304 | + /// |
| 305 | + /// 没有这一条,上面四个断言无法自证「它们有能力失败」—— 一个永远为真的检查等价于没有检查。 |
| 306 | + #[test] |
| 307 | + fn checker_detects_injected_defects() { |
| 308 | + let (zh, en) = packs(); |
| 309 | + |
| 310 | + // ① 模拟「中文包少了键」:必须被报出 |
| 311 | + let mut zh_missing = zh.clone(); |
| 312 | + assert!( |
| 313 | + zh_missing.remove("nav.main"), |
| 314 | + "前置条件:基准包里应有 nav.main" |
| 315 | + ); |
| 316 | + let keys = ["nav.main".to_string()]; |
| 317 | + assert_eq!( |
| 318 | + unresolved(keys.iter(), &zh_missing, &en).len(), |
| 319 | + 1, |
| 320 | + "阴性对照失败:中文包删键后检查器未报错" |
| 321 | + ); |
| 322 | + |
| 323 | + // ② 模拟「键名拼错」:必须被报出 |
| 324 | + let typo = ["nav.mian".to_string()]; |
| 325 | + assert_eq!( |
| 326 | + unresolved(typo.iter(), &zh, &en).len(), |
| 327 | + 1, |
| 328 | + "阴性对照失败:拼错的键名未被报出" |
| 329 | + ); |
| 330 | + |
| 331 | + // ③ 阳性对照:真实存在的键不得被报错 |
| 332 | + let good = ["nav.main".to_string()]; |
| 333 | + assert!( |
| 334 | + unresolved(good.iter(), &zh, &en).is_empty(), |
| 335 | + "阳性对照失败:真实存在的键被误报为缺失" |
| 336 | + ); |
| 337 | + } |
| 338 | +} |
0 commit comments