diff --git a/src/apps/mobile/harmonyos/.gitignore b/src/apps/mobile/harmonyos/.gitignore index 42c31716c8..63b8fc71c1 100644 --- a/src/apps/mobile/harmonyos/.gitignore +++ b/src/apps/mobile/harmonyos/.gitignore @@ -32,3 +32,6 @@ # Generated from shared desktop MiniApp sources by entry/hvigorfile.ts /entry/src/main/resources/rawfile/miniapps/ + +# Generated from en_US/zh_CN element string catalogs by entry/hvigorfile.ts +/entry/src/main/resources/rawfile/string.json diff --git a/src/apps/mobile/harmonyos/entry/hvigorfile.ts b/src/apps/mobile/harmonyos/entry/hvigorfile.ts index 91d8ca97dd..88474b5373 100644 --- a/src/apps/mobile/harmonyos/entry/hvigorfile.ts +++ b/src/apps/mobile/harmonyos/entry/hvigorfile.ts @@ -2,6 +2,8 @@ import { hapTasks } from '@ohos/hvigor-ohos-plugin'; // Include the current shared sources on every IDE and CLI build. require('../miniapps/generate.cjs').generate(); +// Regenerate the HostTextRetranslator i18n rawfile catalog on every build. +require('../i18n/generate.cjs').generate(); export default { system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets index d810c635a6..02cf3dd7c8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets @@ -1,4 +1,4 @@ -import { resourceManager } from '@kit.LocalizationKit'; +import { util } from '@kit.ArkTS'; interface TemplateEntry { key: string; @@ -14,65 +14,231 @@ interface StringCatalog { string: StringEntry[]; } +interface EntryLanguage { + key: string; + language: string; +} + +// Retranslates catalog-backed host text across the app catalog languages. +// +// The rawfile catalog keeps every language in one document: a trailing +// 2-3 letter lowercase dot segment (for example "common.back.zh") tags the +// entry language, and untagged entries are the generator's English block. +// +// Per-language reverse indexes identify which catalog language a stored +// message was rendered in; the matched key is then re-rendered in the output +// language: the constructor language when the message matches another +// catalog language, and otherwise the first other catalog language. That way +// a stored string never re-renders in the language it matched. +// +// Values that several keys share in one language cannot identify their key, +// so ambiguous values are refused rather than guessed, like the legacy +// retranslate policy. export class HostTextRetranslator { - private forward: Map = new Map(); - private reverse: Map = new Map(); - private templates: TemplateEntry[] = []; + private currentLanguage: string = 'en'; + private languages: string[] = []; + private forwardByLanguage: Map> = new Map(); + private reverseByLanguage: Map> = new Map(); + private ambiguousByLanguage: Map> = new Map(); + private templatesByLanguage: Map = new Map(); constructor(locale: string) { - this.loadCatalog(locale); + this.currentLanguage = this.languageTag(locale); + this.loadCatalog(); } - private loadCatalog(locale: string): void { + retranslate(message: string): string { + if (message.length === 0) { + return message; + } + + // 1. Exact match per language O(1) + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const reverse = this.reverseByLanguage.get(language); + if (!reverse) { + continue; + } + const exactKey = reverse.get(message); + if (exactKey !== undefined) { + const translated = this.renderEntry(exactKey, language); + if (translated !== null) { + return translated; + } + } + } + + // 2. Template match per language O(M) + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const templates = this.templatesByLanguage.get(language); + if (!templates) { + continue; + } + for (let templateIndex = 0; templateIndex < templates.length; templateIndex += 1) { + const entry = templates[templateIndex]; + const matched = this.matchTemplate(entry.template, message); + if (matched !== null) { + const translated = this.renderTemplate(entry, language, matched); + if (translated !== null) { + return translated; + } + } + } + } + + return message; + } + + private loadCatalog(): void { try { const resourceManager = getContext(this).resourceManager; - const rawResource = resourceManager.getRawFileContentSync('resources/rawfile/string.json'); - let jsonString = ''; - for (let i = 0; i < rawResource.length; i++) { - jsonString += String.fromCharCode(rawResource[i]); - } + // Rawfile paths are relative to the resources/rawfile root, and the + // content arrives as UTF-8 bytes. Decode multi-byte catalog text with + // TextDecoder instead of per-byte fromCharCode, which mangles it. + const rawResource = resourceManager.getRawFileContentSync('string.json'); + const jsonString = util.TextDecoder.create('utf-8').decodeWithStream(rawResource); const catalog = JSON.parse(jsonString) as StringCatalog; const strings = catalog.string || []; - + for (let index = 0; index < strings.length; index += 1) { const entry = strings[index]; - const key = entry.name; - const value = entry.value; - this.forward.set(key, value); - this.reverse.set(value, key); - if (value.indexOf('%1$s') >= 0 || value.indexOf('%2$s') >= 0) { - this.templates.push({ key, template: value }); + const tagged = this.splitEntryLanguage(entry.name); + this.ensureLanguage(tagged.language); + const forward = this.forwardByLanguage.get(tagged.language); + const reverse = this.reverseByLanguage.get(tagged.language); + const ambiguous = this.ambiguousByLanguage.get(tagged.language); + const templates = this.templatesByLanguage.get(tagged.language); + if (!forward || !reverse || !ambiguous || !templates) { + continue; + } + forward.set(tagged.key, entry.value); + const existingKey = reverse.get(entry.value); + if (existingKey !== undefined) { + if (existingKey !== tagged.key) { + // A second key shares this value within the language, so the + // value cannot identify its key: refuse it instead of letting + // the later entry win like a plain overwrite would. + ambiguous.add(entry.value); + reverse.delete(entry.value); + } + } else if (!ambiguous.has(entry.value)) { + reverse.set(entry.value, tagged.key); + } + if (entry.value.indexOf('%1$s') >= 0 || entry.value.indexOf('%2$s') >= 0) { + templates.push({ key: tagged.key, template: entry.value }); } } + this.pruneAmbiguousTemplates(); } catch (error) { // Fallback: catalog unavailable, retranslate returns original message } } - retranslate(message: string): string { - if (message.length === 0) { - return message; + // A template is trustworthy only when its key's value is unique in every + // catalog language: one shared value in any language leaves the template + // shape unable to identify its key. Refuse such templates in every + // language, matching the legacy hasUniqueTemplate gate. + private pruneAmbiguousTemplates(): void { + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const templates = this.templatesByLanguage.get(language); + if (!templates) { + continue; + } + const kept: TemplateEntry[] = []; + for (let templateIndex = 0; templateIndex < templates.length; templateIndex += 1) { + const entry = templates[templateIndex]; + if (!this.hasAmbiguousTemplate(entry.key)) { + kept.push(entry); + } + } + this.templatesByLanguage.set(language, kept); } + } - // 1. Exact match O(1) - const exactKey = this.reverse.get(message); - if (exactKey) { - return this.forward.get(exactKey) || message; + private hasAmbiguousTemplate(key: string): boolean { + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const forward = this.forwardByLanguage.get(language); + const ambiguous = this.ambiguousByLanguage.get(language); + if (!forward || !ambiguous) { + return true; + } + const value = forward.get(key); + if (value === undefined || ambiguous.has(value)) { + return true; + } } + return false; + } - // 2. Template match O(M) - for (let index = 0; index < this.templates.length; index += 1) { - const entry = this.templates[index]; - const matched = this.matchTemplate(entry.template, message); - if (matched !== null) { - return this.fillTemplate(entry.key, matched); + private ensureLanguage(language: string): void { + if (this.forwardByLanguage.has(language)) { + return; + } + this.forwardByLanguage.set(language, new Map()); + this.reverseByLanguage.set(language, new Map()); + this.ambiguousByLanguage.set(language, new Set()); + this.templatesByLanguage.set(language, []); + this.languages.push(language); + } + + private languageTag(locale: string): string { + const primary = locale.split('-')[0].toLowerCase(); + return primary.length > 0 ? primary : 'en'; + } + + private splitEntryLanguage(name: string): EntryLanguage { + const lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + const suffix = name.slice(lastDot + 1); + if (suffix.length >= 2 && suffix.length <= 3 && suffix.toLowerCase() === suffix) { + return { key: name.slice(0, lastDot), language: suffix }; } } + // Untagged entries are the generator's English block. + return { key: name, language: 'en' }; + } - return message; + private outputLanguageFor(sourceLanguage: string): string { + if (sourceLanguage !== this.currentLanguage && this.forwardByLanguage.has(this.currentLanguage)) { + return this.currentLanguage; + } + for (let index = 0; index < this.languages.length; index += 1) { + const language = this.languages[index]; + if (language !== sourceLanguage) { + return language; + } + } + return sourceLanguage; + } + + private renderEntry(baseKey: string, sourceLanguage: string): string | null { + const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + if (!forward) { + return null; + } + const value = forward.get(baseKey); + return value !== undefined ? value : null; + } + + private renderTemplate(entry: TemplateEntry, sourceLanguage: string, matched: string[]): string | null { + const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + if (!forward) { + return null; + } + const template = forward.get(entry.key); + if (template === undefined) { + return null; + } + if (matched.length === 1) { + return template.replace('%1$s', matched[0]); + } + return template.replace('%1$s', matched[0]).replace('%2$s', matched[1]); } - private matchTemplate(template: string, message: string): string | null { + private matchTemplate(template: string, message: string): string[] | null { // Parse %1$s and %2$s placeholders const firstSlot = template.indexOf('%1$s'); if (firstSlot < 0) { @@ -90,7 +256,7 @@ export class HostTextRetranslator { return null; } const value = message.slice(prefix.length, message.length - suffix.length); - return value; + return [value]; } // Two placeholders: %1$s and %2$s @@ -106,14 +272,6 @@ export class HostTextRetranslator { } const first = body.slice(0, middleAt); const second = body.slice(middleAt + middle.length); - return `${first}${second}`; - } - - private fillTemplate(key: string, matched: string): string { - const template = this.forward.get(key) || ''; - // Replace %1$s and %2$s with the matched value - // For single placeholder, matched is the value - // For two placeholders, matched is concatenated value - return template.replace('%1$s', matched).replace('%2$s', matched); + return [first, second]; } } diff --git a/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets index 569ad12c3a..8be87880f9 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets @@ -1,6 +1,7 @@ import { describe, expect, it } from '@ohos/hypium'; import { AppLocale, APP_LOCALE_EN_US, APP_LOCALE_ZH_CN } from '../main/ets/i18n/AppLocale'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { HostTextRetranslator } from '../main/ets/i18n/HostTextRetranslator'; import { ConnectionErrorPolicy } from '../main/ets/services/ConnectionErrorPolicy'; import { GeneralChatServiceState, @@ -109,5 +110,58 @@ export default function i18nUnitTest() { expect(RemoteI18n.speechLocate()).assertEqual('US'); restoreZhCn(); }); + + it('maps dotted keys to underscore resource names', 0, () => { + restoreZhCn(); + expect(RemoteI18n.t('workspaceTools.browse')).assertEqual('浏览受控设备文件夹'); + expect(RemoteI18n.t('settings.account.section')).assertEqual('账号'); + expect(RemoteI18n.t('settings.general.section')).assertEqual('通用'); + expect(RemoteI18n.t('remote.title')).assertEqual('远程'); + expect(RemoteI18n.t('sidebar.moreSessions')).assertEqual('还有 %1$s 个会话'); + }); + + it('has all keys in string.json matching ets catalogs', 0, () => { + restoreZhCn(); + const zhKeys = RemoteI18n.keys(APP_LOCALE_ZH_CN).sort(); + const enKeys = RemoteI18n.keys(APP_LOCALE_EN_US).sort(); + expect(zhKeys.length).assertEqual(enKeys.length); + expect(zhKeys.length > 0).assertTrue(); + expect(zhKeys.indexOf('settings.title') >= 0).assertTrue(); + expect(zhKeys.indexOf('workspaceTools.browse') >= 0).assertTrue(); + expect(zhKeys.indexOf('sidebar.moreSessions') >= 0).assertTrue(); + }); + }); + + describe('HostTextRetranslator', () => { + it('builds reverse index for O(1) exact matching', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const zhBack = RemoteI18n.translate(APP_LOCALE_ZH_CN, 'common.back'); + const enBack = RemoteI18n.translate(APP_LOCALE_EN_US, 'common.back'); + expect(retranslator.retranslate(zhBack)).assertEqual(enBack); + expect(retranslator.retranslate(enBack)).assertEqual(zhBack); + }); + + it('matches templates with %1$s and %2$s', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const zhSwitching = RemoteI18n.f('remote.settings.deviceSwitching', 'Desk-1'); + expect(zhSwitching).assertEqual('正在切换到 Desk-1…'); + const enSwitching = retranslator.retranslate(zhSwitching); + expect(enSwitching).assertEqual('Switching to Desk-1…'); + const zhMore = RemoteI18n.f('sidebar.moreSessions', '3'); + expect(zhMore).assertEqual('还有 3 个会话'); + const enMore = retranslator.retranslate(zhMore); + expect(enMore).assertEqual('3 more sessions'); + }); + + it('returns original message when no match found', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const unknown = 'This message does not exist in any catalog'; + expect(retranslator.retranslate(unknown)).assertEqual(unknown); + const empty = ''; + expect(retranslator.retranslate(empty)).assertEqual(empty); + }); }); } diff --git a/src/apps/mobile/harmonyos/i18n/generate.cjs b/src/apps/mobile/harmonyos/i18n/generate.cjs new file mode 100644 index 0000000000..d1eaec2472 --- /dev/null +++ b/src/apps/mobile/harmonyos/i18n/generate.cjs @@ -0,0 +1,71 @@ +// Build-time i18n rawfile catalog; generated resources are not edited. +// +// Merges the en_US and zh_CN element string catalogs into the single +// rawfile document HostTextRetranslator reads: English entries keep the +// resource name as-is, Chinese entries gain a ".zh" suffix that +// HostTextRetranslator.splitEntryLanguage recognizes as the language tag. +const fs = require('node:fs'); +const path = require('node:path'); + +const resources = path.resolve(__dirname, '../entry/src/main/resources'); +const output = path.join(resources, 'rawfile', 'string.json'); + +function readElementCatalog(locale) { + const file = path.join(resources, locale, 'element', 'string.json'); + const catalog = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!Array.isArray(catalog.string)) { + throw new Error(`Expected a "string" array in ${locale}/element/string.json`); + } + return catalog.string; +} + +// HostTextRetranslator.splitEntryLanguage treats a trailing dot segment of +// 2-3 lowercase letters as a language tag, so an English catalog name like +// "time.pm" would be misfiled as language "pm". HarmonyOS element names are +// [a-zA-Z0-9_] today; this guard keeps a future dotted name from silently +// corrupting the catalog instead of failing the build loudly. +function assertUntaggedEnglishNames(names) { + for (const name of names) { + const lastDot = name.lastIndexOf('.'); + if (lastDot < 0) { + continue; + } + const suffix = name.slice(lastDot + 1); + if (suffix.length >= 2 && suffix.length <= 3 && suffix.toLowerCase() === suffix) { + throw new Error(`English catalog name "${name}" would be parsed as language "${suffix}"; rename it`); + } + } +} + +function assertKeyLockstep(english, chinese) { + const englishNames = new Set(english.map((entry) => entry.name)); + const chineseNames = new Set(chinese.map((entry) => entry.name)); + for (const entry of english) { + if (!chineseNames.has(entry.name)) { + throw new Error(`en_US catalog key "${entry.name}" is missing from zh_CN`); + } + } + for (const entry of chinese) { + if (!englishNames.has(entry.name)) { + throw new Error(`zh_CN catalog key "${entry.name}" is missing from en_US`); + } + } +} + +function generate() { + const english = readElementCatalog('en_US'); + const chinese = readElementCatalog('zh_CN'); + assertKeyLockstep(english, chinese); + assertUntaggedEnglishNames(english.map((entry) => entry.name)); + const entries = english.concat( + chinese.map((entry) => ({ name: `${entry.name}.zh`, value: entry.value })) + ); + const contents = JSON.stringify({ string: entries }, null, 2) + '\n'; + fs.mkdirSync(path.dirname(output), { recursive: true }); + if (!fs.existsSync(output) || fs.readFileSync(output, 'utf8') !== contents) { + fs.writeFileSync(output, contents); + } +} + +module.exports = { generate }; +if (require.main === module) generate(); diff --git a/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs new file mode 100644 index 0000000000..c1ac91d412 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs @@ -0,0 +1,201 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// Transpile the HostTextRetranslator.ets source +const source = fs.readFileSync( + path.join(__dirname, '../../entry/src/main/ets/i18n/HostTextRetranslator.ets'), + 'utf8' +); +const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText; + +// Mock the '@kit.ArkTS' util.TextDecoder dependency of the compiled module. +// getRawFileContentSync delivers UTF-8 bytes on device, so the harness must +// decode them the same way instead of assuming one byte equals one character. +function mockKitRequire(name) { + if (name === '@kit.ArkTS') { + return { + util: { + TextDecoder: { + create() { + return { + decodeWithStream(bytes) { + return Buffer.from(bytes).toString('utf8'); + } + }; + } + } + } + }; + } + return {}; +} + +// Mock getContext to return a fake resourceManager with test catalog data +function createMockContext(catalog) { + return { + resourceManager: { + getRawFileContentSync(filename) { + // Serve UTF-8-encoded bytes, matching device rawfile reads. + return Array.from(Buffer.from(JSON.stringify(catalog), 'utf8')); + } + } + }; +} + +// Helper to create a retranslator with a given catalog +function createRetranslator(catalog) { + const exportsObject = {}; + const mockContext = createMockContext(catalog); + const originalGetContext = global.getContext; + global.getContext = () => mockContext; + try { + new Function('require', 'exports', compiled)(mockKitRequire, exportsObject); + // The constructor reads the catalog through getContext, so it must run + // while the override is active. + return new exportsObject.HostTextRetranslator('en-US'); + } finally { + if (originalGetContext === undefined) { + delete global.getContext; + } else { + global.getContext = originalGetContext; + } + } +} + +// Test catalog with single and dual placeholder templates +const testCatalog = { + string: [ + { name: 'common.back', value: 'Back' }, + { name: 'common.back.zh', value: '返回' }, + { name: 'sidebar.moreSessions', value: '%1$s more sessions' }, + { name: 'sidebar.moreSessions.zh', value: '还有 %1$s 个会话' }, + { name: 'remote.settings.deviceSwitching', value: 'Switching to %1$s…' }, + { name: 'remote.settings.deviceSwitching.zh', value: '正在切换到 %1$s…' }, + { name: 'dual.placeholder', value: '%1$s and %2$s' }, + { name: 'dual.placeholder.zh', value: '%1$s 和 %2$s' }, + { name: 'status.notConnected', value: 'Not connected' }, + { name: 'status.notConnected.zh', value: '未连接' }, + // Two distinct keys share one value per language: the value is ambiguous + // and must not be re-rendered through either key. + { name: 'duplicate.first', value: 'Duplicate value' }, + { name: 'duplicate.first.zh', value: '重复值' }, + { name: 'duplicate.second', value: 'Duplicate value' }, + { name: 'duplicate.second.zh', value: '重复值' }, + // The English templates of the two keys collide, so neither key's + // template can identify a match in any language. + { name: 'collision.first', value: '%1$s items' }, + { name: 'collision.first.zh', value: '%1$s 项' }, + { name: 'collision.second', value: '%1$s items' }, + { name: 'collision.second.zh', value: '%1$s 条目' } + ] +}; + +test('exact match returns translated string', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Back'), '返回'); + assert.equal(retranslator.retranslate('返回'), 'Back'); + assert.equal(retranslator.retranslate('Not connected'), '未连接'); + assert.equal(retranslator.retranslate('未连接'), 'Not connected'); +}); + +test('ambiguous value is returned as-is rather than guessed', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Duplicate value'), 'Duplicate value'); +}); + +test('ambiguous value refusal applies to every catalog language', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('重复值'), '重复值'); +}); + +test('duplicate catalog values keep unique neighbors translatable', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Back'), '返回'); + assert.equal(retranslator.retranslate('返回'), 'Back'); + assert.equal(retranslator.retranslate('Not connected'), '未连接'); +}); + +test('template with an ambiguous value is refused in every language', () => { + const retranslator = createRetranslator(testCatalog); + // collision.first and collision.second share the English value + // '%1$s items', so neither key's template may match, even in Chinese + // where the values differ. + assert.equal(retranslator.retranslate('3 items'), '3 items'); + assert.equal(retranslator.retranslate('3 项'), '3 项'); + assert.equal(retranslator.retranslate('3 条目'), '3 条目'); + // Unambiguous templates keep working. + assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); +}); + +test('single placeholder template matches and fills correctly', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); + // Chinese to English + assert.equal(retranslator.retranslate('还有 5 个会话'), '5 more sessions'); +}); + +test('single placeholder with prefix and suffix', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('Switching to Desk-1…'), '正在切换到 Desk-1…'); + // Chinese to English + assert.equal(retranslator.retranslate('正在切换到 Desk-2…'), 'Switching to Desk-2…'); +}); + +test('dual placeholder template matches and fills correctly', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese: "foo and bar" -> "foo 和 bar" + assert.equal(retranslator.retranslate('foo and bar'), 'foo 和 bar'); + // Chinese to English: "foo 和 bar" -> "foo and bar" + assert.equal(retranslator.retranslate('foo 和 bar'), 'foo and bar'); +}); + +test('dual placeholder with different values', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('hello and world'), 'hello 和 world'); + // Chinese to English + assert.equal(retranslator.retranslate('你好 和 世界'), '你好 and 世界'); +}); + +test('returns original message when no match found', () => { + const retranslator = createRetranslator(testCatalog); + const unknown = 'This message does not exist in any catalog'; + assert.equal(retranslator.retranslate(unknown), unknown); +}); + +test('returns empty string for empty input', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate(''), ''); +}); + +test('template match returns null for non-matching prefix', () => { + const retranslator = createRetranslator(testCatalog); + // "Switched to Desk-1…" does not start with the template prefix "Switching to " + assert.equal(retranslator.retranslate('Switched to Desk-1…'), 'Switched to Desk-1…'); +}); + +test('template match returns null for non-matching suffix', () => { + const retranslator = createRetranslator(testCatalog); + // "3 more sessions xyz" doesn't end with the expected suffix + assert.equal(retranslator.retranslate('3 more sessions xyz'), '3 more sessions xyz'); +}); + +test('dual placeholder with empty middle', () => { + const retranslator = createRetranslator(testCatalog); + // "foo and bar" where middle is " and " + assert.equal(retranslator.retranslate('A and B'), 'A 和 B'); +}); + +test('dual placeholder with complex values', () => { + const retranslator = createRetranslator(testCatalog); + // Values containing spaces + assert.equal(retranslator.retranslate('hello world and foo bar'), 'hello world 和 foo bar'); + assert.equal(retranslator.retranslate('你好世界 和 foo bar'), '你好世界 and foo bar'); +});