mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
feat(i18n): add ui locale runtime
This commit is contained in:
138
tests/i18n-boundary.mjs
Normal file
138
tests/i18n-boundary.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
// ST-BME: i18n import boundary enforcement
|
||||
// Ensures that prompt/data modules (prompting/**, retrieval/injector.js,
|
||||
// graph/schema.js, sync/**, vector/**) do NOT import from the i18n module.
|
||||
// This is a static analysis guard: i18n is UI-only and must not leak into
|
||||
// data-pipeline or prompt-building code.
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────────────────
|
||||
|
||||
const PROJECT_ROOT = new URL("..", import.meta.url).pathname;
|
||||
|
||||
const BOUNDARY_PATTERNS = [
|
||||
// i18n module import patterns to forbid
|
||||
/from\s+["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']/,
|
||||
/require\s*\(\s*["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']\s*\)/,
|
||||
/import\s*\(\s*["'](?:\.\.\/)+i18n(?:\/[^"']*)?["']\s*\)/,
|
||||
];
|
||||
|
||||
const BOUNDARY_MODULES = [
|
||||
// prompting/ — all files in the prompting directory
|
||||
{ dir: "prompting", files: null },
|
||||
// retrieval/injector.js specifically
|
||||
{ dir: "retrieval", files: ["injector.js"] },
|
||||
// graph/schema.js specifically
|
||||
{ dir: "graph", files: ["schema.js"] },
|
||||
// sync/ — all files in the sync directory
|
||||
{ dir: "sync", files: null },
|
||||
// vector/ — all files in the vector directory
|
||||
{ dir: "vector", files: null },
|
||||
];
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function readProjectFile(relativePath) {
|
||||
return readFileSync(PROJECT_ROOT + relativePath, "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a source file contains any forbidden i18n import pattern.
|
||||
* Returns violations found, or an empty array.
|
||||
*/
|
||||
function checkI18nImports(source, filePath) {
|
||||
const violations = [];
|
||||
for (const pattern of BOUNDARY_PATTERNS) {
|
||||
const match = source.match(pattern);
|
||||
if (match) {
|
||||
const lineNum = source.substring(0, match.index).split("\n").length;
|
||||
violations.push({ filePath, line: lineNum, match: match[0].trim() });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all .js files in a directory (non-recursive for simplicity; the
|
||||
* project's restricted dirs are flat).
|
||||
*/
|
||||
function getJsFilesInDir(dirPath) {
|
||||
const entries = readdirSync(PROJECT_ROOT + dirPath, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".js"))
|
||||
.map((entry) => entry.name);
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const allViolations = [];
|
||||
|
||||
for (const entry of BOUNDARY_MODULES) {
|
||||
const fileList = entry.files || getJsFilesInDir(entry.dir);
|
||||
for (const filename of fileList) {
|
||||
const relativePath = `${entry.dir}/${filename}`;
|
||||
let source;
|
||||
try {
|
||||
source = readProjectFile(relativePath);
|
||||
} catch {
|
||||
console.warn(`[i18n-boundary] SKIP — file not found: ${relativePath}`);
|
||||
continue;
|
||||
}
|
||||
const violations = checkI18nImports(source, relativePath);
|
||||
allViolations.push(...violations);
|
||||
}
|
||||
}
|
||||
|
||||
if (allViolations.length > 0) {
|
||||
const report = allViolations
|
||||
.map(
|
||||
(v) => ` ${v.filePath}:${v.line} — ${v.match}`,
|
||||
)
|
||||
.join("\n");
|
||||
assert.fail(
|
||||
`i18n import boundary violation(s) found in ${allViolations.length} location(s):\n${report}\n\n` +
|
||||
"i18n is UI-only. Prompt/data modules (prompting/**, retrieval/injector.js, " +
|
||||
"graph/schema.js, sync/**, vector/**) must not import i18n.",
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: verify that the boundary modules themselves are valid JS by
|
||||
// checking for obvious syntax issues (e.g., unmatched braces, obviously
|
||||
// malformed import statements that might hide i18n imports).
|
||||
// This is a lightweight sanity check, not a full parse.
|
||||
|
||||
const MINIMAL_FILE_COUNT = {
|
||||
prompting: 9,
|
||||
retrieval: 1, // injector.js only
|
||||
graph: 1, // schema.js only
|
||||
sync: 13,
|
||||
vector: 6,
|
||||
};
|
||||
|
||||
for (const entry of BOUNDARY_MODULES) {
|
||||
if (entry.files) {
|
||||
for (const filename of entry.files) {
|
||||
const relativePath = `${entry.dir}/${filename}`;
|
||||
try {
|
||||
const source = readProjectFile(relativePath);
|
||||
// Ensure the file has at least some content
|
||||
assert.ok(
|
||||
source.trim().length > 0,
|
||||
`${relativePath} is empty — cannot verify boundary compliance`,
|
||||
);
|
||||
} catch {
|
||||
// Already warned above — skip
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const dirFiles = getJsFilesInDir(entry.dir);
|
||||
assert.ok(
|
||||
dirFiles.length >= (MINIMAL_FILE_COUNT[entry.dir] || 1),
|
||||
`${entry.dir} expected at least ${MINIMAL_FILE_COUNT[entry.dir]} .js files for boundary coverage, got ${dirFiles.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("i18n boundary enforcement tests passed");
|
||||
console.log(` Scanned modules: ${BOUNDARY_MODULES.length} directories / files`);
|
||||
console.log(` Violations found: ${allViolations.length}`);
|
||||
47
tests/i18n-catalog.mjs
Normal file
47
tests/i18n-catalog.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import zhCN from "../i18n/zh-CN.js";
|
||||
import enUS from "../i18n/en-US.js";
|
||||
|
||||
function catalogKeys(catalog) {
|
||||
return Object.keys(catalog).sort();
|
||||
}
|
||||
|
||||
function extractInterpolationParams(template) {
|
||||
const params = new Set();
|
||||
const re = /\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}/g;
|
||||
let match;
|
||||
while ((match = re.exec(String(template)))) {
|
||||
params.add(match[1]);
|
||||
}
|
||||
return [...params].sort();
|
||||
}
|
||||
|
||||
const zhKeys = catalogKeys(zhCN);
|
||||
const enKeys = catalogKeys(enUS);
|
||||
const onlyZh = zhKeys.filter((key) => !Object.prototype.hasOwnProperty.call(enUS, key));
|
||||
const onlyEn = enKeys.filter((key) => !Object.prototype.hasOwnProperty.call(zhCN, key));
|
||||
|
||||
assert.deepEqual(onlyZh, [], `keys in zh-CN but missing in en-US: ${onlyZh.join(", ")}`);
|
||||
assert.deepEqual(onlyEn, [], `keys in en-US but missing in zh-CN: ${onlyEn.join(", ")}`);
|
||||
assert.equal(zhKeys.length, enKeys.length, "zh-CN and en-US catalog key counts differ");
|
||||
|
||||
for (const key of zhKeys) {
|
||||
assert.equal(typeof zhCN[key], "string", `zh-CN ${key} must be a string`);
|
||||
assert.equal(typeof enUS[key], "string", `en-US ${key} must be a string`);
|
||||
assert.ok(zhCN[key].trim(), `zh-CN ${key} must not be empty`);
|
||||
assert.ok(enUS[key].trim(), `en-US ${key} must not be empty`);
|
||||
assert.deepEqual(
|
||||
extractInterpolationParams(zhCN[key]),
|
||||
extractInterpolationParams(enUS[key]),
|
||||
`interpolation params differ for ${key}`,
|
||||
);
|
||||
}
|
||||
|
||||
assert.ok(zhKeys.length >= 60, "Phase 0 catalog should include a meaningful seed set");
|
||||
assert.ok(
|
||||
zhKeys.some((key) => extractInterpolationParams(zhCN[key]).length > 0),
|
||||
"catalog should include at least one interpolated string",
|
||||
);
|
||||
|
||||
console.log("i18n catalog tests passed");
|
||||
138
tests/i18n-dom.mjs
Normal file
138
tests/i18n-dom.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createI18nStatus,
|
||||
formatUiStatusMeta,
|
||||
formatUiStatusText,
|
||||
getLocale,
|
||||
getLocaleMode,
|
||||
hydrateI18n,
|
||||
resolveLocale,
|
||||
setLocale,
|
||||
t,
|
||||
} from "../i18n/index.js";
|
||||
|
||||
class FakeElement {
|
||||
constructor(tagName = "div") {
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = new Map();
|
||||
this.textContent = "";
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes.set(name, String(value));
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
matches(selector) {
|
||||
const attr = selector.match(/^\[([\w-]+)\]$/)?.[1];
|
||||
return Boolean(attr && this.attributes.has(attr));
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
const attr = selector.match(/^\[([\w-]+)\]$/)?.[1];
|
||||
if (!attr) return [];
|
||||
const result = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.children) {
|
||||
if (child.attributes.has(attr)) result.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(this);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function withNavigatorLanguages(languages, fn) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: {
|
||||
language: languages[0] || "",
|
||||
languages,
|
||||
},
|
||||
});
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(globalThis, "navigator", descriptor);
|
||||
else delete globalThis.navigator;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(resolveLocale("zh-CN"), "zh-CN");
|
||||
assert.equal(resolveLocale("en-US"), "en-US");
|
||||
assert.equal(resolveLocale("nonsense"), "zh-CN");
|
||||
assert.equal(resolveLocale("auto", { navigatorLanguages: ["en-GB"] }), "en-US");
|
||||
assert.equal(resolveLocale("auto", { navigatorLanguages: ["zh-Hant"] }), "zh-CN");
|
||||
assert.equal(resolveLocale("auto", { hostLocale: "en-US", navigatorLanguages: ["zh-CN"] }), "en-US");
|
||||
assert.equal(resolveLocale("auto", { navigatorLanguages: ["fr-FR"] }), "zh-CN");
|
||||
|
||||
withNavigatorLanguages(["en-AU"], () => {
|
||||
assert.equal(setLocale("auto"), "en-US");
|
||||
});
|
||||
assert.equal(getLocaleMode(), "auto");
|
||||
assert.equal(getLocale(), "en-US");
|
||||
|
||||
setLocale("zh-CN");
|
||||
assert.equal(t("common.save"), "保存");
|
||||
assert.equal(t("notice.loading", { stage: "提取" }), "提取进行中…");
|
||||
assert.equal(t("missing.key"), "missing.key");
|
||||
assert.equal(t("missing.key", {}, { fallback: "兜底" }), "兜底");
|
||||
assert.equal(t("recall.card.memoryCount", { count: 3 }), "记忆 3");
|
||||
|
||||
setLocale("en-US");
|
||||
assert.equal(t("common.save"), "Save");
|
||||
assert.equal(t("notice.loading", { stage: "Extraction" }), "Extraction in progress…");
|
||||
assert.equal(t("recall.card.memoryCount", { count: 3 }), "3 memories");
|
||||
|
||||
const root = new FakeElement("section");
|
||||
const text = new FakeElement("span");
|
||||
text.setAttribute("data-i18n", "common.cancel");
|
||||
const title = new FakeElement("button");
|
||||
title.setAttribute("data-i18n-title", "common.close");
|
||||
const placeholder = new FakeElement("input");
|
||||
placeholder.setAttribute("data-i18n-placeholder", "common.loading");
|
||||
const aria = new FakeElement("button");
|
||||
aria.setAttribute("data-i18n-aria-label", "common.delete");
|
||||
root.appendChild(text);
|
||||
root.appendChild(title);
|
||||
root.appendChild(placeholder);
|
||||
root.appendChild(aria);
|
||||
|
||||
hydrateI18n(root);
|
||||
assert.equal(text.textContent, "Cancel");
|
||||
assert.equal(title.getAttribute("title"), "Close");
|
||||
assert.equal(placeholder.getAttribute("placeholder"), "Loading…");
|
||||
assert.equal(aria.getAttribute("aria-label"), "Delete");
|
||||
|
||||
setLocale("zh-CN");
|
||||
hydrateI18n(root);
|
||||
assert.equal(text.textContent, "取消");
|
||||
assert.equal(title.getAttribute("title"), "关闭");
|
||||
|
||||
const status = createI18nStatus({
|
||||
textKey: "status.idle",
|
||||
textFallback: "待命",
|
||||
metaKey: "status.initial.runtime.detail",
|
||||
metaFallback: "准备就绪",
|
||||
level: "idle",
|
||||
});
|
||||
assert.equal(formatUiStatusText(status), "待命");
|
||||
assert.equal(formatUiStatusMeta(status), "准备就绪");
|
||||
setLocale("en-US");
|
||||
assert.equal(formatUiStatusText(status), "Idle");
|
||||
assert.equal(formatUiStatusMeta(status), "Ready");
|
||||
assert.equal(formatUiStatusText({ text: "legacy" }), "legacy");
|
||||
|
||||
console.log("i18n DOM/runtime tests passed");
|
||||
Reference in New Issue
Block a user