diff --git a/i18n/en-US.js b/i18n/en-US.js new file mode 100644 index 0000000..bebbc79 --- /dev/null +++ b/i18n/en-US.js @@ -0,0 +1,95 @@ +// ST-BME UI-only i18n catalog: English. +// Keep this catalog for frontend chrome/status text only. Do not use it for +// prompt construction, graph node content, persisted memories, or LLM schemas. + +export default { + "common.appName": "ST-BME", + "common.cancel": "Cancel", + "common.close": "Close", + "common.confirm": "Confirm", + "common.delete": "Delete", + "common.edit": "Edit", + "common.empty": "No data", + "common.loading": "Loading…", + "common.retry": "Retry", + "common.save": "Save", + "common.status": "Status", + "common.unknown": "Unknown", + + "error.auditFailed": "Audit failed", + "error.deleteBlocked": "The node was removed from the graph, but write-back may be blocked. Check graph state.", + "error.deleteFailed": "Delete failed", + "error.nodeNotFound": "Node no longer exists", + "error.saveBlocked": "Content updated, but write-back to chat metadata may be blocked. Check graph state.", + "error.saveFailed": "Save failed", + "error.syncFailed": "Sync failed", + + "graph.scope.characterPov": "Character POV · {name}", + "graph.scope.objective": "Objective Layer", + "graph.scope.unknownCharacter": "Unknown character", + "graph.scope.userPov": "User POV", + + "i18n.locale.auto": "Auto", + "i18n.locale.enUS": "English", + "i18n.locale.setting.help": "Only affects ST-BME panel text, notices, and status messages. It does not translate chat content, memory nodes, or prompts.", + "i18n.locale.setting.label": "Interface Language", + "i18n.locale.zhCN": "Simplified Chinese", + + "notice.completed": "{stage} completed", + "notice.failed": "{stage} failed", + "notice.generic": "ST-BME", + "notice.loading": "{stage} in progress…", + "notice.partial": "{stage} partially succeeded", + + "panel.entry.floatingTooltip": "BME Memory Graph", + "panel.entry.menuLabel": "Memory Graph", + "panel.entry.openFailed": "Memory Graph panel failed to load. Check the console for details.", + "panel.title": "ST-BME Memory Graph", + "panel.tab.actions": "Actions", + "panel.tab.cognition": "Cognition", + "panel.tab.config": "Settings", + "panel.tab.dashboard": "Overview", + "panel.tab.extraction": "Extraction", + "panel.tab.persistence": "Persistence State", + "panel.tab.pipeline": "Pipeline Overview", + "panel.tab.recall": "Recall", + "panel.tab.settings": "Settings", + "panel.tab.tasks": "Tasks", + "panel.tab.timeline": "Task Timeline", + "panel.tab.trace": "Message Trace", + "panel.tab.vector": "Vector", + + "persistence.loadState.error": "Load failed", + "persistence.loadState.loaded": "Loaded", + "persistence.loadState.loading": "Loading", + "persistence.loadState.noChat": "No chat is currently open", + "persistence.persistState.completed": "Completed", + "persistence.persistState.failed": "Persistence failed", + "persistence.persistState.idle": "Idle", + "persistence.persistState.pending": "Awaiting persistence confirmation", + + "recall.card.deleteConfirm": "Delete this persisted recall injection?", + "recall.card.memoryCount": "{count} memories", + "recall.card.title": "Relevant Memory Recall", + + "stage.extraction": "Extraction", + "stage.history": "History Recovery", + "stage.recall": "Recall", + "stage.vector": "Vector", + + "status.aborted": "Aborted", + "status.failed": "Failed", + "status.idle": "Idle", + "status.loading": "Loading…", + "status.partial": "Partially succeeded", + "status.ready": "Ready", + "status.running": "Running", + "status.skipped": "Skipped", + "status.success": "Succeeded", + "status.waiting": "Waiting", + + "status.initial.extraction.detail": "Extraction has not run yet", + "status.initial.recall.detail": "Recall has not run yet", + "status.initial.runtime.detail": "Ready", + "status.initial.vector.detail": "Vector tasks have not run yet", +}; diff --git a/i18n/glossary.js b/i18n/glossary.js new file mode 100644 index 0000000..ce114cf --- /dev/null +++ b/i18n/glossary.js @@ -0,0 +1,30 @@ +// UI terminology reference for ST-BME i18n. This is documentation data for +// translators and tests, not a runtime prompt/schema source. + +export const UI_I18N_GLOSSARY = Object.freeze({ + "记忆图谱": "Memory Graph", + "记忆节点": "Memory Node", + "召回": "Recall", + "提取": "Extraction", + "注入": "Injection", + "注入预览": "Injection Preview", + "历史恢复": "History Recovery", + "图谱持久化": "Graph Persistence", + "检查点": "Checkpoint", + "副本同步": "Replica Sync", + "向量": "Vector", + "向量重建": "Vector Rebuild", + "向量空间": "Vector Space", + "向量清单": "Vector Manifest", + "嵌入服务商": "Embedding Provider", + "服务端增强": "Server Enhancement", + "纯前端模式": "Frontend-only Mode", + "权威输入": "Authoritative Input", + "角色 POV": "Character POV", + "用户 POV": "User POV", + "客观层": "Objective Layer", + "楼层": "Turn", + "持久召回": "Persisted Recall", + "召回卡片": "Recall Card", + "记忆浏览器": "Memory Browser", +}); diff --git a/i18n/index.js b/i18n/index.js new file mode 100644 index 0000000..cc402ce --- /dev/null +++ b/i18n/index.js @@ -0,0 +1,173 @@ +import zhCN from "./zh-CN.js"; +import enUS from "./en-US.js"; + +export const SUPPORTED_LOCALES = Object.freeze(["zh-CN", "en-US"]); +export const DEFAULT_LOCALE = "zh-CN"; +export const DEFAULT_LOCALE_MODE = "auto"; + +export const catalogs = Object.freeze({ + "zh-CN": zhCN, + "en-US": enUS, +}); + +let localeMode = DEFAULT_LOCALE_MODE; +let resolvedLocale = DEFAULT_LOCALE; + +function normalizeLocaleTag(value) { + const tag = String(value || "").trim(); + if (!tag) return ""; + const lower = tag.toLowerCase(); + if (lower.startsWith("zh")) return "zh-CN"; + if (lower.startsWith("en")) return "en-US"; + return ""; +} + +function getNavigatorLanguages() { + const nav = globalThis.navigator; + const languages = Array.isArray(nav?.languages) ? nav.languages : []; + if (nav?.language) return [...languages, nav.language]; + return languages; +} + +export function resolveLocale(mode = DEFAULT_LOCALE_MODE, options = {}) { + const requested = String(mode || DEFAULT_LOCALE_MODE); + if (SUPPORTED_LOCALES.includes(requested)) return requested; + + if (requested !== "auto") return DEFAULT_LOCALE; + + const hostLocale = normalizeLocaleTag(options.hostLocale); + if (hostLocale) return hostLocale; + + const languages = Array.isArray(options.navigatorLanguages) + ? options.navigatorLanguages + : getNavigatorLanguages(); + for (const language of languages) { + const locale = normalizeLocaleTag(language); + if (locale) return locale; + } + + return DEFAULT_LOCALE; +} + +export function setLocale(mode = DEFAULT_LOCALE_MODE, options = {}) { + localeMode = SUPPORTED_LOCALES.includes(mode) || mode === "auto" + ? mode + : DEFAULT_LOCALE_MODE; + resolvedLocale = resolveLocale(localeMode, options); + if (globalThis.document?.documentElement) { + globalThis.document.documentElement.lang = resolvedLocale; + } + return resolvedLocale; +} + +export function getLocale() { + return resolvedLocale; +} + +export function getLocaleMode() { + return localeMode; +} + +export function hasI18nKey(key, locale = resolvedLocale) { + return Object.prototype.hasOwnProperty.call(catalogs[locale] || {}, key) || + Object.prototype.hasOwnProperty.call(zhCN, key); +} + +function interpolate(template, params = {}) { + return String(template).replace(/\{\{?\s*([A-Za-z_][\w.-]*)\s*\}?\}/g, (match, name) => { + if (Object.prototype.hasOwnProperty.call(params || {}, name)) { + const value = params[name]; + return value == null ? "" : String(value); + } + return match; + }); +} + +export function t(key, params = {}, options = {}) { + const locale = options.locale || resolvedLocale; + const catalog = catalogs[locale] || zhCN; + const fallbackCatalog = catalogs[DEFAULT_LOCALE] || zhCN; + const template = Object.prototype.hasOwnProperty.call(catalog, key) + ? catalog[key] + : fallbackCatalog[key]; + if (template == null) { + return options.fallback ?? key; + } + return interpolate(template, params); +} + +function hydrateAttribute(root, selector, attributeName, setter) { + const elements = []; + if (root?.matches?.(selector)) elements.push(root); + if (typeof root?.querySelectorAll === "function") { + elements.push(...root.querySelectorAll(selector)); + } + for (const element of elements) { + const key = element.getAttribute?.(attributeName); + if (!key) continue; + setter(element, t(key)); + } +} + +export function hydrateI18n(root = globalThis.document) { + if (!root) return; + hydrateAttribute(root, "[data-i18n]", "data-i18n", (element, value) => { + element.textContent = value; + }); + hydrateAttribute(root, "[data-i18n-title]", "data-i18n-title", (element, value) => { + element.setAttribute?.("title", value); + }); + hydrateAttribute(root, "[data-i18n-placeholder]", "data-i18n-placeholder", (element, value) => { + element.setAttribute?.("placeholder", value); + }); + hydrateAttribute(root, "[data-i18n-aria-label]", "data-i18n-aria-label", (element, value) => { + element.setAttribute?.("aria-label", value); + }); +} + +export function createI18nStatus({ + textKey = "", + textParams = {}, + textFallback = "", + metaKey = "", + metaParams = {}, + metaFallback = "", + level = "idle", + extra = {}, +} = {}) { + return { + ...extra, + textKey, + textParams, + textFallback, + metaKey, + metaParams, + metaFallback, + text: textKey ? t(textKey, textParams, { fallback: textFallback }) : textFallback, + meta: metaKey ? t(metaKey, metaParams, { fallback: metaFallback }) : metaFallback, + level, + updatedAt: Date.now(), + }; +} + +export function formatUiStatusText(status) { + if (typeof status === "string") return status; + if (status?.textKey) { + return t(status.textKey, status.textParams || {}, { + fallback: status.textFallback ?? status.text ?? status.textKey, + }); + } + return status?.text ?? ""; +} + +export function formatUiStatusMeta(status) { + if (typeof status === "string") return status; + if (status?.metaKey) { + return t(status.metaKey, status.metaParams || {}, { + fallback: status.metaFallback ?? status.meta ?? status.metaKey, + }); + } + return status?.meta ?? ""; +} + +setLocale(DEFAULT_LOCALE_MODE); diff --git a/i18n/zh-CN.js b/i18n/zh-CN.js new file mode 100644 index 0000000..6acc532 --- /dev/null +++ b/i18n/zh-CN.js @@ -0,0 +1,95 @@ +// ST-BME UI-only i18n catalog: Simplified Chinese. +// Keep this catalog for frontend chrome/status text only. Do not use it for +// prompt construction, graph node content, persisted memories, or LLM schemas. + +export default { + "common.appName": "ST-BME", + "common.cancel": "取消", + "common.close": "关闭", + "common.confirm": "确认", + "common.delete": "删除", + "common.edit": "编辑", + "common.empty": "暂无数据", + "common.loading": "加载中…", + "common.retry": "重试", + "common.save": "保存", + "common.status": "状态", + "common.unknown": "未知", + + "error.auditFailed": "审计失败", + "error.deleteBlocked": "节点已从图中移除,但写回可能被拦截,请查看图谱状态", + "error.deleteFailed": "删除失败", + "error.nodeNotFound": "节点已不存在", + "error.saveBlocked": "内容已更新,但写回聊天元数据可能被拦截,请查看图谱状态", + "error.saveFailed": "保存失败", + "error.syncFailed": "同步失败", + + "graph.scope.characterPov": "角色 POV · {name}", + "graph.scope.objective": "客观层", + "graph.scope.unknownCharacter": "未知角色", + "graph.scope.userPov": "用户 POV", + + "i18n.locale.auto": "自动", + "i18n.locale.enUS": "English", + "i18n.locale.setting.help": "只影响 ST-BME 面板、提示和状态文案,不会翻译聊天内容、记忆节点或提示词。", + "i18n.locale.setting.label": "界面语言", + "i18n.locale.zhCN": "简体中文", + + "notice.completed": "{stage}已完成", + "notice.failed": "{stage}失败", + "notice.generic": "ST-BME", + "notice.loading": "{stage}进行中…", + "notice.partial": "{stage}部分成功", + + "panel.entry.floatingTooltip": "BME 记忆图谱", + "panel.entry.menuLabel": "记忆图谱", + "panel.entry.openFailed": "记忆图谱面板加载失败,请查看控制台报错", + "panel.title": "ST-BME 记忆图谱", + "panel.tab.actions": "操作", + "panel.tab.cognition": "角色认知", + "panel.tab.config": "设置", + "panel.tab.dashboard": "总览", + "panel.tab.extraction": "提取 Extraction", + "panel.tab.persistence": "持久化状态", + "panel.tab.pipeline": "管线总览", + "panel.tab.recall": "召回 Recall", + "panel.tab.settings": "配置", + "panel.tab.tasks": "任务", + "panel.tab.timeline": "任务流水", + "panel.tab.trace": "消息追踪", + "panel.tab.vector": "向量 Vector", + + "persistence.loadState.error": "加载失败", + "persistence.loadState.loaded": "已加载", + "persistence.loadState.loading": "加载中", + "persistence.loadState.noChat": "当前尚未进入聊天", + "persistence.persistState.completed": "已完成", + "persistence.persistState.failed": "持久化失败", + "persistence.persistState.idle": "待命", + "persistence.persistState.pending": "等待正式持久化确认", + + "recall.card.deleteConfirm": "确认删除这条持久召回注入?", + "recall.card.memoryCount": "记忆 {count}", + "recall.card.title": "相关记忆召回", + + "stage.extraction": "提取", + "stage.history": "历史恢复", + "stage.recall": "召回", + "stage.vector": "向量", + + "status.aborted": "已中止", + "status.failed": "失败", + "status.idle": "待命", + "status.loading": "加载中…", + "status.partial": "部分成功", + "status.ready": "准备就绪", + "status.running": "运行中", + "status.skipped": "已跳过", + "status.success": "成功", + "status.waiting": "等待中", + + "status.initial.extraction.detail": "尚未执行提取", + "status.initial.recall.detail": "尚未执行召回", + "status.initial.runtime.detail": "准备就绪", + "status.initial.vector.detail": "尚未执行向量任务", +}; diff --git a/package.json b/package.json index ee45d7f..79a7d98 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ "test:indexeddb-migration": "node tests/indexeddb-migration.mjs", "test:native-layout-parity": "node tests/native-layout-parity.mjs", "test:trivial-input": "node tests/trivial-user-input.mjs", + "test:i18n-catalog": "node tests/i18n-catalog.mjs", + "test:i18n-dom": "node tests/i18n-dom.mjs", + "test:i18n-boundary": "node tests/i18n-boundary.mjs", + "test:i18n": "npm run test:i18n-catalog && npm run test:i18n-dom && npm run test:i18n-boundary", "bench:graph-layout": "node tests/perf/graph-layout-bench.mjs", "bench:persist-delta": "node tests/perf/persist-delta-bench.mjs", "bench:persist-load": "node tests/perf/persist-load-bench.mjs", diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index a02b2cd..8363255 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -202,6 +202,7 @@ export const defaultSettings = { backgroundMaintenanceMaxQueueItems: 24, // UI 面板 + uiLocale: "auto", noticeDisplayMode: "normal", panelTheme: "crimson", graphLocalStorageMode: "auto", diff --git a/tests/i18n-boundary.mjs b/tests/i18n-boundary.mjs new file mode 100644 index 0000000..158cb64 --- /dev/null +++ b/tests/i18n-boundary.mjs @@ -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}`); diff --git a/tests/i18n-catalog.mjs b/tests/i18n-catalog.mjs new file mode 100644 index 0000000..b50595e --- /dev/null +++ b/tests/i18n-catalog.mjs @@ -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"); diff --git a/tests/i18n-dom.mjs b/tests/i18n-dom.mjs new file mode 100644 index 0000000..d50f615 --- /dev/null +++ b/tests/i18n-dom.mjs @@ -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");