Merge A7 QA tooling (check-keys + scanner) into Wave 1 integration

This commit is contained in:
lijian19 2026-09-09 14:21:42 +08:00
commit 22696b5098
6 changed files with 703 additions and 0 deletions

View file

@ -0,0 +1,149 @@
// T-02 key 集合一致性校验库(纯 Node,无第三方依赖)。
// 对比 en / zh-CN 同名 namespace 的:
// 1. 递归扁平化 key 集合(含嵌套路径,点分隔)
// 2. 嵌套结构形状(叶子 vs 对象)
// 3. 每叶子上的 {{var}} 插值变量集合
// 输出对称差清单,供 CLI / CI 门禁(G3)消费。
// 只读、不改写任何文件。
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_LOCALES = ["en", "zh-CN"];
// 从 JSON 值中提取插值变量 `{{name}}`(i18next 标准插值)。
export function extractInterpolationVars(value) {
const vars = new Set();
if (typeof value !== "string") return vars;
const re = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.]*)\s*\}\}/g;
let m;
while ((m = re.exec(value)) !== null) {
vars.add(m[1]);
}
return vars;
}
// 递归扁平化一个字典:返回 Map<keyPath, { value, isLeaf }>。
// 对象值递归,叶子(字符串/数字/布尔/null/数组)记录 value。
export function flattenDict(obj, prefix = "") {
const out = new Map();
for (const [k, v] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
const sub = flattenDict(v, path);
for (const [p, info] of sub) out.set(p, info);
out.set(path, { value: undefined, isLeaf: false }); // 记录分支节点形状
} else {
out.set(path, { value: v, isLeaf: true });
}
}
return out;
}
function parseJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
}
// 列出目录下顶层文件(不含子目录),去扩展名,返回 { name, absPath, ext }。
export function listLocaleFiles(dir) {
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
return readdirSync(dir)
.filter((f) => statSync(join(dir, f)).isFile())
.map((f) => {
const dot = f.lastIndexOf(".");
const name = dot > 0 ? f.slice(0, dot) : f;
return { name, absPath: join(dir, f), ext: dot > 0 ? f.slice(dot + 1) : "" };
});
}
// 命名空间之间没有共同文件也能校验;只对有同名校的文件做比对。
export function collectNamespaces(localeDir) {
const files = listLocaleFiles(localeDir).filter((f) => f.ext === "json");
return new Map(files.map((f) => [f.name, parseJson(f.absPath)]));
}
// 对比一对字典(en vs zh),返回差异详情。
export function diffDicts(a, b) {
const fa = flattenDict(a);
const fb = flattenDict(b);
const aPaths = new Set(fa.keys());
const bPaths = new Set(fb.keys());
const missing = []; // 在 b 中有、a 中没有(即 zh 缺 en 的 key)
const extra = []; // 在 a 中有、b 没有
const shapeMismatch = [];
const interpolationMismatch = [];
// 以 a (en) 为基准:遍历所有叶子路径
for (const p of aPaths) {
if (!bPaths.has(p)) {
extra.push(p); // en 有、zh 无 -> zh 缺
}
}
for (const p of bPaths) {
if (!aPaths.has(p)) {
missing.push(p); // zh 有、en 无
}
}
// 结构/插值比对仅在双方都存在叶子时进行
for (const p of aPaths) {
if (!bPaths.has(p)) continue;
const infoA = fa.get(p);
const infoB = fb.get(p);
if (infoA.isLeaf !== infoB.isLeaf) {
shapeMismatch.push(p);
continue;
}
if (!infoA.isLeaf) continue;
const va = extractInterpolationVars(infoA.value);
const vb = extractInterpolationVars(infoB.value);
const vaArr = [...va].sort();
const vbArr = [...vb].sort();
if (vaArr.join("|") !== vbArr.join("|")) {
interpolationMismatch.push({ key: p, en: vaArr, zh: vbArr });
}
}
return { missing, extra, shapeMismatch, interpolationMismatch };
}
// 顶层入口:给定 en 目录与 zh 目录,返回每个共同 namespace 的差异。
// 同时报告只有一方存在的 namespace(enOnly / zhOnly)。
export function diffLocaleDirs(enDir, zhDir, { locales = DEFAULT_LOCALES } = {}) {
const enNs = collectNamespaces(enDir);
const zhNs = collectNamespaces(zhDir);
const enNames = new Set(enNs.keys());
const zhNames = new Set(zhNs.keys());
const results = [];
for (const name of enNames) {
if (zhNames.has(name)) {
const d = diffDicts(enNs.get(name), zhNs.get(name));
results.push({ namespace: name, ...d });
} else {
results.push({
namespace: name,
missing: [],
extra: [],
shapeMismatch: [],
interpolationMismatch: [],
onlyInEn: true,
});
}
}
const onlyInZh = [...zhNames].filter((n) => !enNames.has(n));
return { namespaces: results, enOnly: [...enNames].filter((n) => !zhNames.has(n)), zhOnly: onlyInZh };
}
export function hasDiff(diff) {
if (diff.enOnly.length > 0 || diff.zhOnly.length > 0) return true;
return diff.namespaces.some(
(n) =>
n.missing.length > 0 ||
n.extra.length > 0 ||
n.shapeMismatch.length > 0 ||
n.interpolationMismatch.length > 0 ||
n.onlyInEn,
);
}

View file

@ -0,0 +1,123 @@
#!/usr/bin/env node
// T-02 key 集合一致性校验 CLI(下方红色:只读、不写入字典)。
// 校验 en 与 zh-CN 同名 namespace 的 key 集合、嵌套结构、插值变量集合完全一致。
//
// 用法:
// node scripts/i18n/check-keys.mjs
// node scripts/i18n/check-keys.mjs --en <enDir> --zh <zhDir>
//
// 退出码:一致 -> 0;不一致 -> 1。适合 CI 门禁(G3)。
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { diffLocaleDirs, hasDiff } from "./check-keys-lib.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const DEFAULT_EN = resolve(root, "src/locales/en");
const DEFAULT_ZH = resolve(root, "src/locales/zh-CN");
function parseArgs(argv) {
const args = { en: DEFAULT_EN, zh: DEFAULT_ZH };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--en" && argv[i + 1]) args.en = resolve(argv[++i]);
else if (argv[i] === "--zh" && argv[i + 1]) args.zh = resolve(argv[++i]);
else if (argv[i] === "--help" || argv[i] === "-h") args.help = true;
}
return args;
}
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`T-02 key consistency checker
Usage:
node scripts/i18n/check-keys.mjs [--en <enDir>] [--zh <zhDir>]
Default dirs (relative to dashboard root):
--en src/locales/en
--zh src/locales/zh-CN
Exit code 0 when key sets match, 1 when any difference is found.`);
process.exit(0);
}
const { en, zh } = parseArgs(process.argv.slice(2));
let diff;
try {
diff = diffLocaleDirs(en, zh);
} catch (e) {
console.error(`check-keys: failed to read locale dirs (${e.message})`);
console.error(` en: ${en}`);
console.error(` zh: ${zh}`);
process.exit(2);
}
const failed = hasDiff(diff);
let anyLine = false;
const print = (line) => {
console.log(line);
anyLine = true;
};
if (diff.enOnly.length > 0) {
print(`[EN-ONLY] namespace 仅存在于 ${en}:`);
for (const n of diff.enOnly) print(` - ${n}`);
}
if (diff.zhOnly.length > 0) {
print(`[ZH-ONLY] namespace 仅存在于 ${zh}:`);
for (const n of diff.zhOnly) print(` - ${n}`);
}
for (const ns of diff.namespaces) {
const nsProblems =
ns.onlyInEn ||
ns.missing.length > 0 ||
ns.extra.length > 0 ||
ns.shapeMismatch.length > 0 ||
ns.interpolationMismatch.length > 0;
if (!nsProblems) {
print(
`[OK] ${ns.namespace}: key set matched (` +
`${ns.extra.length} missing / ${ns.shapeMismatch.length} shape / ` +
`${ns.interpolationMismatch.length} interpolation)`,
);
continue;
}
if (ns.onlyInEn) {
print(`[DIFF] ${ns.namespace}: namespace 只有 ${en} 侧`);
continue;
}
print(`[DIFF] ${ns.namespace}:`);
if (ns.missing.length > 0) {
print(` missing (zh 有、en 无):`);
for (const k of ns.missing) print(` - ${k}`);
}
if (ns.extra.length > 0) {
print(` extra (en 有、zh 无):`);
for (const k of ns.extra) print(` - ${k}`);
}
if (ns.shapeMismatch.length > 0) {
print(` shape mismatch (叶子/对象不一致):`);
for (const k of ns.shapeMismatch) print(` - ${k}`);
}
if (ns.interpolationMismatch.length > 0) {
print(` interpolation mismatch ({{...}} 变量不一致):`);
for (const { key: k, en: ev, zh: zv } of ns.interpolationMismatch)
print(` - ${k}: en=[${ev.join(", ")}] zh=[${zv.join(", ")}]`);
}
}
if (!anyLine) {
console.log(`OK: 未发现 ${en} / ${zh} 下的 locale 文件(或目录不存在)。`);
}
if (failed) {
console.error("check-keys: FAILED — en/zh key sets are not consistent.");
process.exit(1);
}
console.log("check-keys: PASS — en/zh key sets consistent.");
process.exit(0);

View file

@ -0,0 +1,139 @@
// T-03 硬编码字符串扫描库(纯 Node,无第三方依赖)。
// 只报告疑似"用户可见"的硬编码文案,绝不改写代码、绝不自动生成 key。
// 供人工 review(方案 §3.4 硬约束:工具只报告)。
//
// 策略(line-based 启发式,适合作为报告工具而非精确解析器):
// 1. JSX 文本:`>文案<` 形态的非空白/非注释/非纯符号文本。
// 2. 可访问性/文案属性字面量:aria-label / title / placeholder / alt / label。
// 3. 常见未国际化消息入口:toast( ... ) / notify( ... ) / useMessage / useToast。
// 排除:
// - 已 t() / <Trans> 包裹的文案
// - 纯数字/符号/URL/路径/正则/占位符
// - 标识符(模式串)、CSS 类、data-slot、样式令牌
// - 明显是模型名/API 字段/日志/代码示例的标识符
import { readFileSync } from "node:fs";
// ---------- 单个候选 ----------
// { file, line, col, kind, text }
// 判文案属性值是否"疑似用户可见文案"(过滤纯符号/数字/占位)。
const PLACEHOLDER_RE = /[{}]/;
const URL_RE = /^(https?:)?\/\//i;
const PATH_RE = /^[./~][\w./-]*$/;
export function isLikelyCopy(s) {
const t = s.trim();
if (t.length === 0) return false;
// 纯数字/符号组合(不含字母)
if (/^[\d\s.,%:+\-*/()_'"`]+$/.test(t)) return false;
// 占位符(含 {{ }} 或 { } 渲染表达式)——由插值负责,不视为硬编码
if (PLACEHOLDER_RE.test(t)) return false;
// URL / 路径
if (URL_RE.test(t) || PATH_RE.test(t)) return false;
// 单 token 且形式为 CSS 类/标识符(连字符/下划线/无空格)——排除模型名、类名、变量
if (/^[A-Za-z0-9][\w-]*$/.test(t)) return false;
// 必须含字母
if (!/[A-Za-z]/.test(t)) return false;
// 至少两个词,保证是短语/句子而非单词级标识符片段
const words = t.split(/\s+/).filter(Boolean);
if (words.length < 2) return false;
return true;
}
// 过滤已 <Trans> 包裹(或其参数为 t() 表达式)的文本。
// 行内出现 <Trans 时,本行 JSX 文本很可能已国际化,跳过以避免误报;
// 其余候选(属性/入口)由各自模式精确排除表达式与单 token,无需整行 t() 判断。
export function lineHasTransWrapper(context) {
return /<Trans[\s>]/.test(context);
}
// 从单行提取 `attr="value"` 形态的属性字面量。
// attr 限定于文案属性集合。
const ATTR_TEXT_RE = /\b(aria-label|title|placeholder|alt)="([^"]*)"/g;
export function findCopyAttributes(line) {
const out = [];
let m;
ATTR_TEXT_RE.lastIndex = 0;
while ((m = ATTR_TEXT_RE.exec(line)) !== null) {
out.push({ attr: m[1], text: m[2], col: m.index });
}
return out;
}
// 从 JSX 文本提取 `>text<`。只捕获紧跟 `>`、后跟 `</` 或 `/>` 的文本节点。
// 形如:<Tag>hello world</Tag> 或 <Tag>hi</Tag>
export function findJsxText(line) {
const out = [];
const re = />([^<>{}\n]+)</g;
let m;
while ((m = re.exec(line)) !== null) {
const t = m[1].trim();
if (t.length > 0) {
out.push({ text: t, col: m.index + 1 });
}
}
return out;
}
// 常见未国际化消息入口:toast( / notify( / setToast( / useMessage / enqueueSnackbar / addToast
const MSG_ENTRY_RE = /\b(toast|notify|enqueueSnackbar|addToast|showToast|useMessage|useToast)\s*\(([^)]*)\)/g;
export function findMessageEntries(line) {
const out = [];
let m;
MSG_ENTRY_RE.lastIndex = 0;
while ((m = MSG_ENTRY_RE.exec(line)) !== null) {
const arg = m[2].trim();
// 仅字符串字面量(单/双引号)视为候选;对象/表达式跳过
const strHit = arg.match(/^(['"])(.*?)\1/);
if (strHit) {
out.push({ entry: m[1], text: strHit[2], col: m.index });
}
}
return out;
}
// 主扫描:给定文件绝对路径与内容行数组,返回候选列表。
export function scanContent(source, filePath) {
const lines = source.split("\n");
const candidates = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const stripped = line.replace(/\/\/.*$/, "").replace(/\/\*[\s\S]*?\*\//g, "");
if (stripped.trim() === "") continue;
// 该行是否含 <Trans>(其 JSX 文本已国际化,跳过 JSX 文本候选项)
const trans = lineHasTransWrapper(line);
// 1) 文案属性(aria-label/title/placeholder/alt 的纯字符串字面量;
// t("...") 形式是 aria-label={t(...)},不匹配本引号模式,天然排除)
for (const { attr, text, col } of findCopyAttributes(stripped)) {
if (isLikelyCopy(text)) {
candidates.push({ file: filePath, line: i + 1, col, kind: `attr:${attr}`, text });
}
}
// 2) JSX 文本
if (!trans) {
for (const { text, col } of findJsxText(stripped)) {
if (isLikelyCopy(text)) {
candidates.push({ file: filePath, line: i + 1, col, kind: "jsx-text", text });
}
}
}
// 3) 消息入口
for (const { entry, text, col } of findMessageEntries(line)) {
if (isLikelyCopy(text)) {
candidates.push({ file: filePath, line: i + 1, col, kind: `entry:${entry}`, text });
}
}
}
return candidates;
}
export function scanFile(filePath) {
const source = readFileSync(filePath, "utf8");
return scanContent(source, filePath);
}

View file

@ -0,0 +1,80 @@
#!/usr/bin/env node
// T-03 硬编码字符串扫描 CLI —— 只报告,不自动改写,不自动生成 key(方案 §3.4)。
// 扫描 .ts / .tsx 中疑似"用户可见"的硬编码文案,输出供人工 review 的清单。
//
// 用法:
// node scripts/i18n/scan-hardcoded.mjs [dir|file ...]
// 默认扫描 src/(若存在)。
// 可传多个目录/文件;目录会递归遍历 .ts/.tsx。
//
// 输出带 "REPORT ONLY" 标注;退出码 0(报告工具不因命中失败)。
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { scanContent } from "./scan-hardcoded-lib.mjs";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const DEFAULT_SRC = resolve(root, "src");
const IGNORED_DIRS = new Set(["node_modules", ".next", "out", "dist", ".git", "coverage"]);
const TARGET_EXT = new Set([".ts", ".tsx"]);
function collectFiles(path, acc = []) {
const st = statSync(path);
if (st.isFile()) {
if (TARGET_EXT.has(extname(path))) acc.push(path);
return acc;
}
for (const entry of readdirSync(path)) {
const full = join(path, entry);
if (statSync(full).isDirectory()) {
if (IGNORED_DIRS.has(entry)) continue;
collectFiles(full, acc);
} else if (TARGET_EXT.has(extname(full))) {
acc.push(full);
}
}
return acc;
}
const inputs = process.argv.slice(2).filter((a) => !a.startsWith("-"));
const targets = inputs.length > 0 ? inputs : [DEFAULT_SRC];
const files = [];
for (const t of targets) {
if (!existsSync(t)) {
console.error(`scan-hardcoded: path not found: ${t}`);
process.exit(2);
}
collectFiles(t, files);
}
// 按文件分组去重排序
const unique = [...new Set(files)].sort();
const grouped = new Map();
let total = 0;
for (const f of unique) {
const source = readFileSync(f, "utf8");
const hits = scanContent(source, f);
if (hits.length > 0) grouped.set(f, hits);
total += hits.length;
}
console.log(
"TODO REPORT — 疑似硬编码\"用户可见\"文案候选,供人工 review,工具不做改写。",
);
console.log(`扫描 ${unique.length} 个文件,${total} 个候选命中。\n`);
for (const [f, hits] of grouped) {
console.log(`# ${f}`);
for (const h of hits) {
console.log(` ${h.line}:${h.col} [${h.kind}] ${JSON.stringify(h.text)}`);
}
console.log("");
}
if (total === 0) {
console.log(`OK: 未发现疑似硬编码的用户可见文案候选。`);
}

View file

@ -0,0 +1,112 @@
import { describe, it, expect, afterAll } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
flattenDict,
extractInterpolationVars,
diffDicts,
diffLocaleDirs,
hasDiff,
} from "../../scripts/i18n/check-keys-lib.mjs";
describe("flattenDict", () => {
it("flattens nested objects with dotted paths", () => {
const flat = flattenDict({ a: { b: { c: "x" } }, d: "y" });
expect(flat.get("a.b.c")).toEqual({ value: "x", isLeaf: true });
expect(flat.get("d")).toEqual({ value: "y", isLeaf: true });
// 分支节点也被记录形状
expect(flat.get("a")).toEqual({ value: undefined, isLeaf: false });
});
});
describe("extractInterpolationVars", () => {
it("extracts {{var}} names", () => {
expect([...extractInterpolationVars("Hello {{name}}, you have {{count}}")).toEqual(["name", "count"]);
});
it("returns empty set for non-strings", () => {
expect(extractInterpolationVars(42).size).toBe(0);
expect(extractInterpolationVars("no placeholders").size).toBe(0);
});
});
describe("diffDicts", () => {
it("reports missing/extra/insert keys and interpolation mismatch", () => {
const en = {
title: "Hello",
name: "Hi {{name}}",
nested: { keep: "same", gone: "en only" },
};
const zh = {
title: "你好",
name: "你好 {{name}},{{count}}", // 插值变量不一致(多 count)
nested: { keep: "相同", added: "zh only" },
brandNew: "新增",
};
const d = diffDicts(en, zh);
expect(d.extra.sort()).toEqual(["nested.gone"]); // en 有、zh 无
expect(d.missing.sort()).toEqual(["brandNew", "nested.added"]); // zh 有、en 无
expect(d.shapeMismatch).toEqual([]);
expect(d.interpolationMismatch.map((x) => x.key)).toEqual(["name"]);
});
it("reports shape mismatch when a leaf becomes an object", () => {
const d = diffDicts({ key: "x" }, { key: { sub: "y" } });
expect(d.shapeMismatch).toEqual(["key"]);
});
});
describe("diffLocaleDirs", () => {
const tmp = mkdtempSync(join(tmpdir(), "check-keys-test-"));
const enDir = join(tmp, "en");
const zhDir = join(tmp, "zh-CN");
mkdirSync(enDir);
mkdirSync(zhDir);
const write = (dir: string, name: string, obj: unknown) =>
writeFileSync(join(dir, `${name}.json`), JSON.stringify(obj));
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
it("returns no diff when key sets match", () => {
write(enDir, "common", { ok: "OK", cancel: "Cancel", greeting: "Hi {{name}}" });
write(zhDir, "common", { ok: "确定", cancel: "取消", greeting: "你好 {{name}}" });
const d = diffLocaleDirs(enDir, zhDir);
expect(hasDiff(d)).toBe(false);
});
it("flags a namespace missing in zh (enOnly)", () => {
// en 有 common,zh 没有
write(enDir, "common", { ok: "OK" });
write(enDir, "onlyEn", { x: "1" });
write(zhDir, "common", { ok: "确定" });
const d = diffLocaleDirs(enDir, zhDir);
expect(d.enOnly).toContain("onlyEn");
expect(hasDiff(d)).toBe(true);
});
it("flags interpolation mismatch across dirs", () => {
write(enDir, "common", { greeting: "Hi {{name}}" });
write(zhDir, "common", { greeting: "你好 {{name}},{{count}}" });
const d = diffLocaleDirs(enDir, zhDir);
expect(d.namespaces.find((n) => n.namespace === "common").interpolationMismatch.length).toBe(1);
expect(hasDiff(d)).toBe(true);
});
it("flags a namespace only present in zh (zhOnly)", () => {
write(enDir, "common", { a: "1" });
write(zhDir, "common", { a: "1" });
write(zhDir, "zhOnlyNs", { b: "2" });
const d = diffLocaleDirs(enDir, zhDir);
expect(d.zhOnly).toContain("zhOnlyNs");
expect(hasDiff(d)).toBe(true);
});
it("flags a missing key across dirs", () => {
write(enDir, "common", { ok: "OK", cancel: "Cancel" });
write(zhDir, "common", { ok: "确定" }); // 缺 cancel
const d = diffLocaleDirs(enDir, zhDir);
expect(d.namespaces.find((n) => n.namespace === "common").extra).toContain("cancel");
expect(hasDiff(d)).toBe(true);
});
});

View file

@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import {
isLikelyCopy,
findJsxText,
findCopyAttributes,
findMessageEntries,
scanContent,
} from "../../scripts/i18n/scan-hardcoded-lib.mjs";
describe("isLikelyCopy", () => {
it("accepts user-facing phrases", () => {
expect(isLikelyCopy("Save Changes")).toBe(true);
expect(isLikelyCopy("Tenant not found")).toBe(true);
});
it("rejects numbers/symbols, identifiers, urls, paths, placeholders", () => {
expect(isLikelyCopy("123")).toBe(false);
expect(isLikelyCopy("42,000")).toBe(false);
expect(isLikelyCopy("gpt-4o")).toBe(false); // 模型名
expect(isLikelyCopy("api_keys")).toBe(false); // API 字段
expect(isLikelyCopy("bg-red-500")).toBe(false); // CSS 类
expect(isLikelyCopy("https://example.com")).toBe(false);
expect(isLikelyCopy("/api/v1/models")).toBe(false);
expect(isLikelyCopy("Hello {{name}}")).toBe(false); // 插值占位
expect(isLikelyCopy("")).toBe(false);
});
});
describe("findJsxText", () => {
it("extracts visible JSX text between tags", () => {
const hits = findJsxText("<Button>Save Changes</Button>");
expect(hits.map((h) => h.text)).toEqual(["Save Changes"]);
});
it("ignores empty and expression containers", () => {
expect(findJsxText("<div>{value}</div>").map((h) => h.text)).toEqual([]);
expect(findJsxText("<div></div>").map((h) => h.text)).toEqual([]);
});
});
describe("findCopyAttributes", () => {
it("extracts a11y/copy attribute literals", () => {
const hits = findCopyAttributes(`aria-label="Close dialog" title="More info"`);
expect(hits.map((h) => [h.attr, h.text])).toEqual([
["aria-label", "Close dialog"],
["title", "More info"],
]);
});
it("does not match unrelated attributes", () => {
expect(findCopyAttributes(`className="x" data-slot="y"`)).toEqual([]);
});
});
describe("findMessageEntries", () => {
it("finds toast/notify string literal args", () => {
expect(findMessageEntries(`toast("Saved successfully")`).map((h) => [h.entry, h.text])).toEqual([
["toast", "Saved successfully"],
]);
});
it("skips non-string args", () => {
expect(findMessageEntries(`toast(errorObj)`)).toEqual([]);
});
});
describe("scanContent", () => {
const SAMPLE = `
import { t } from "i18next";
export const label = "not-copy"; // identifier-style single token, excluded
export const aria = 'aria-label="Close dialog"';
function Comp() {
return (
<div>
<button aria-label="Open settings">Open settings</button>
<span>{t("common:already.translated")}</span>
<span>Tenant unavailable</span>
<div data-slot="wrapper">pure</div>
</div>
);
}
toast("Tenant created");
`;
it("finds jsx-text, attributes, and message entries; skips t()/identifiers/data-slot", () => {
const hits = scanContent(SAMPLE, "/fake/Comp.tsx");
const texts = hits.map((h) => h.text);
expect(texts).toContain("Open settings"); // 属性(attr) 与 JSX 文本
expect(texts).toContain("Tenant unavailable"); // JSX 文本
expect(texts).toContain("Tenant created"); // toast 入口
// t() 已国际化、单 token 标识符、data-slot 内部纯词不应被命中
expect(texts).not.toContain("common:already.translated");
expect(texts).not.toContain("not-copy");
expect(texts).not.toContain("pure");
// 每个候选都带 file/line/kind
for (const h of hits) {
expect(h.file).toBe("/fake/Comp.tsx");
expect(typeof h.line).toBe("number");
expect(typeof h.kind).toBe("string");
}
});
});