diff --git a/src/core/baseProcessor.ts b/src/core/baseProcessor.ts index ae075be..c04fc09 100644 --- a/src/core/baseProcessor.ts +++ b/src/core/baseProcessor.ts @@ -132,10 +132,13 @@ abstract class BaseProcessor { abstract loadIntoTree(filePathOrBuffer: ProcessorInput): Promise; // Process texts (e.g., apply translations) and return new file/buffer + // If targetLocale is provided, it attempts to add the language to the file (multilingual support) + // Otherwise, it replaces the existing text (translation/localization) abstract processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + targetLocale?: string ): Promise; // Save tree structure back to file/buffer diff --git a/src/processors/applePanelsProcessor.ts b/src/processors/applePanelsProcessor.ts index c1f1e2a..bb790a5 100644 --- a/src/processors/applePanelsProcessor.ts +++ b/src/processors/applePanelsProcessor.ts @@ -419,7 +419,8 @@ class ApplePanelsProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { // Load the tree, apply translations, and save to new file const tree = await this.loadIntoTree(filePathOrBuffer); diff --git a/src/processors/astericsGridProcessor.ts b/src/processors/astericsGridProcessor.ts index 761c446..0267ef5 100644 --- a/src/processors/astericsGridProcessor.ts +++ b/src/processors/astericsGridProcessor.ts @@ -1310,7 +1310,8 @@ class AstericsGridProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + targetLocale?: string ): Promise { await Promise.resolve(); let content = readTextFromInput(filePathOrBuffer); @@ -1323,7 +1324,7 @@ class AstericsGridProcessor extends BaseProcessor { const grdFile: AstericsGridFile = JSON.parse(content); // Apply translations directly to the JSON structure for comprehensive coverage - this.applyTranslationsToGridFile(grdFile, translations); + this.applyTranslationsToGridFile(grdFile, translations, targetLocale); // Write the translated file writeTextToPath(outputPath, JSON.stringify(grdFile, null, 2)); @@ -1332,39 +1333,86 @@ class AstericsGridProcessor extends BaseProcessor { private applyTranslationsToGridFile( grdFile: AstericsGridFile, - translations: Map + translations: Map, + targetLocale?: string ): void { grdFile.grids.forEach((grid: GridData) => { // Translate grid labels if (grid.label) { - Object.keys(grid.label).forEach((lang) => { - const originalText = grid.label[lang]; - if (originalText && translations.has(originalText)) { + if (typeof grid.label === 'string') { + const originalText = grid.label as string; + if (translations.has(originalText)) { const translation = translations.get(originalText); if (translation !== undefined) { - grid.label[lang] = translation; + if (targetLocale) { + // Upgrade to object format + grid.label = { + en: originalText, // Assume 'en' for legacy string labels + [targetLocale]: translation, + }; + } else { + grid.label = translation as any; + } } } - }); + } else { + Object.keys(grid.label).forEach((lang) => { + const originalText = grid.label[lang]; + if (originalText && translations.has(originalText)) { + const translation = translations.get(originalText); + if (translation !== undefined) { + if (targetLocale) { + grid.label[targetLocale] = translation; + } else { + grid.label[lang] = translation; + } + } + } + }); + } } // Translate grid elements grid.gridElements.forEach((element: GridElement) => { // Translate element labels if (element.label) { - Object.keys(element.label).forEach((lang) => { - const originalText = element.label[lang]; - if (originalText && translations.has(originalText)) { + if (typeof element.label === 'string') { + const originalText = element.label as string; + if (translations.has(originalText)) { const translation = translations.get(originalText); if (translation !== undefined) { - element.label[lang] = translation; + if (targetLocale) { + // Upgrade to object format + element.label = { + en: originalText, // Assume 'en' for legacy string labels + [targetLocale]: translation, + }; + } else { + element.label = translation as any; + } } } - }); + } else { + Object.keys(element.label).forEach((lang) => { + const originalText = element.label[lang]; + if (originalText && translations.has(originalText)) { + const translation = translations.get(originalText); + if (translation !== undefined) { + if (targetLocale) { + element.label[targetLocale] = translation; + } else { + element.label[lang] = translation; + } + } + } + }); + } } // Translate word forms - if (element.wordForms) { + // Word forms are typically specific to a language, so adding a target locale might require structure change + // For now, we only translate in place if no targetLocale, or skip if targetLocale is set (as word forms are language specific) + if (element.wordForms && !targetLocale) { element.wordForms.forEach((wordForm: WordForm) => { if (wordForm.value && translations.has(wordForm.value)) { const translation = translations.get(wordForm.value); @@ -1377,13 +1425,17 @@ class AstericsGridProcessor extends BaseProcessor { // Translate action-specific texts element.actions.forEach((action: GridAction) => { - this.applyTranslationsToAction(action, translations); + this.applyTranslationsToAction(action, translations, targetLocale); }); }); }); } - private applyTranslationsToAction(action: GridAction, translations: Map): void { + private applyTranslationsToAction( + action: GridAction, + translations: Map, + targetLocale?: string + ): void { switch (action.modelName) { case 'GridActionSpeakCustom': if (action.speakText && typeof action.speakText === 'object') { @@ -1393,7 +1445,11 @@ class AstericsGridProcessor extends BaseProcessor { if (typeof originalText === 'string' && translations.has(originalText)) { const translation = translations.get(originalText); if (translation !== undefined) { - speakTextMap[lang] = translation; + if (targetLocale) { + speakTextMap[targetLocale] = translation; + } else { + speakTextMap[lang] = translation; + } } } }); diff --git a/src/processors/dotProcessor.ts b/src/processors/dotProcessor.ts index 069df8e..c43250c 100644 --- a/src/processors/dotProcessor.ts +++ b/src/processors/dotProcessor.ts @@ -217,7 +217,8 @@ class DotProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { await Promise.resolve(); const content = readTextFromInput(filePathOrBuffer); diff --git a/src/processors/excelProcessor.ts b/src/processors/excelProcessor.ts index 9edc1e5..671c90b 100644 --- a/src/processors/excelProcessor.ts +++ b/src/processors/excelProcessor.ts @@ -53,7 +53,8 @@ export class ExcelProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { await Promise.resolve(); console.warn('ExcelProcessor.processTexts is not implemented yet.'); diff --git a/src/processors/gridsetProcessor.ts b/src/processors/gridsetProcessor.ts index e72a1a6..b8e3f06 100644 --- a/src/processors/gridsetProcessor.ts +++ b/src/processors/gridsetProcessor.ts @@ -1603,7 +1603,8 @@ class GridsetProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { // Load the tree, apply translations, and save to new file const tree = await this.loadIntoTree(filePathOrBuffer); diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index d1f41af..cf4e350 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -33,6 +33,7 @@ import { isNodeRuntime, } from '../utils/io'; import { openZipFromInput, type ZipAdapter } from '../utils/zip'; +import { writeBinaryToPath } from '../utils/io'; const OBF_FORMAT_VERSION = 'open-board-0.1'; @@ -80,6 +81,7 @@ interface ObfBoard { grid?: ObfGrid; images?: any[]; sounds?: any[]; + strings?: { [locale: string]: { [key: string]: string } }; } class ObfProcessor extends BaseProcessor { @@ -203,6 +205,8 @@ class ObfProcessor extends BaseProcessor { private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { const sourceButtons = boardData.buttons || []; + const strings = boardData.strings; + const locale = boardData.locale; // Calculate page ID first (used to make button IDs unique) const isZipEntry = @@ -218,6 +222,24 @@ class ObfProcessor extends BaseProcessor { const buttons: AACButton[] = await Promise.all( sourceButtons.map(async (btn: ObfButton): Promise => { + // Resolve strings based on locale if strings dictionary is present + let label = btn.label || ''; + let vocalization = btn.vocalization || btn.label || ''; + + if (strings && locale && strings[locale]) { + const localeStrings = strings[locale]; + if (btn.label && localeStrings[btn.label]) { + label = localeStrings[btn.label]; + } + if (btn.vocalization && localeStrings[btn.vocalization]) { + vocalization = localeStrings[btn.vocalization]; + } else if (!btn.vocalization && btn.label && localeStrings[btn.label]) { + // If no vocalization but label was translated, use translated label as vocalization + // (matching default behavior where vocalization falls back to label) + vocalization = localeStrings[btn.label]; + } + } + const semanticAction: AACSemanticAction = btn.load_board ? { category: AACSemanticCategory.NAVIGATION, @@ -231,10 +253,10 @@ class ObfProcessor extends BaseProcessor { : { category: AACSemanticCategory.COMMUNICATION, intent: AACSemanticIntent.SPEAK_TEXT, - text: String(btn?.vocalization || btn?.label || ''), + text: String(vocalization), fallback: { type: 'SPEAK', - message: String(btn?.vocalization || btn?.label || ''), + message: String(vocalization), }, }; @@ -261,8 +283,8 @@ class ObfProcessor extends BaseProcessor { return new AACButton({ // Make button ID unique by combining page ID and button ID id: `${pageId}::${btn?.id || ''}`, - label: String(btn?.label || ''), - message: String(btn?.vocalization || btn?.label || ''), + label: String(label), + message: String(vocalization), visibility: mapObfVisibility(btn.hidden), style: { backgroundColor: btn.background_color, @@ -448,6 +470,17 @@ class ObfProcessor extends BaseProcessor { tree.metadata.id = boardData.id; if (boardData.url) tree.metadata.url = boardData.url; if (boardData.locale) tree.metadata.languages = [boardData.locale]; + if (boardData.strings) { + tree.metadata.obfStrings = boardData.strings; + if (!tree.metadata.languages) tree.metadata.languages = []; + // Add all available languages from strings + const stringLocales = Object.keys(boardData.strings); + stringLocales.forEach((l) => { + if (!tree.metadata.languages?.includes(l)) { + tree.metadata.languages?.push(l); + } + }); + } tree.rootId = page.id; return tree; @@ -477,6 +510,16 @@ class ObfProcessor extends BaseProcessor { if (asJson.locale) { tree.metadata.languages = [asJson.locale]; } + if (asJson.strings) { + tree.metadata.obfStrings = asJson.strings; + if (!tree.metadata.languages) tree.metadata.languages = []; + const stringLocales = Object.keys(asJson.strings); + stringLocales.forEach((l) => { + if (!tree.metadata.languages?.includes(l)) { + tree.metadata.languages?.push(l); + } + }); + } tree.rootId = page.id; return tree; @@ -532,6 +575,16 @@ class ObfProcessor extends BaseProcessor { tree.metadata.id = boardData.id; if (boardData.url) tree.metadata.url = boardData.url; if (boardData.locale) tree.metadata.languages = [boardData.locale]; + if (boardData.strings) { + tree.metadata.obfStrings = boardData.strings; + if (!tree.metadata.languages) tree.metadata.languages = []; + const stringLocales = Object.keys(boardData.strings); + stringLocales.forEach((l) => { + if (!tree.metadata.languages?.includes(l)) { + tree.metadata.languages?.push(l); + } + }); + } tree.rootId = page.id; } } else { @@ -617,6 +670,7 @@ class ObfProcessor extends BaseProcessor { metadata?.description && page.id === metadata?.defaultHomePageId ? metadata.description : page.descriptionHtml || boardName, + strings: metadata?.obfStrings, grid: { rows, columns, @@ -654,8 +708,15 @@ class ObfProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + targetLocale?: string ): Promise { + if (targetLocale) { + // Multilingual mode: operate on raw JSON/ZIP structure to preserve/update string lists + return this.processTextsRaw(filePathOrBuffer, translations, outputPath, targetLocale); + } + + // Legacy mode: translation replacement via Tree // Load the tree, apply translations, and save to new file const tree = await this.loadIntoTree(filePathOrBuffer); @@ -691,6 +752,131 @@ class ObfProcessor extends BaseProcessor { return readBinaryFromInput(outputPath); } + private async processTextsRaw( + filePathOrBuffer: ProcessorInput, + translations: Map, + outputPath: string, + targetLocale: string + ): Promise { + // Helper to process a single board object + const processBoardObject = (board: ObfBoard): void => { + if (!board.strings) { + board.strings = {}; + } + const sourceLocale = board.locale || 'en'; + if (!board.strings[sourceLocale]) { + board.strings[sourceLocale] = {}; + } + if (!board.strings[targetLocale]) { + board.strings[targetLocale] = {}; + } + + const sourceStrings = board.strings[sourceLocale]; + const targetStrings = board.strings[targetLocale]; + + // Build reverse map of value -> keys for source locale + const valueToKeys = new Map(); + Object.entries(sourceStrings).forEach(([key, value]) => { + if (!valueToKeys.has(value)) { + valueToKeys.set(value, []); + } + valueToKeys.get(value)?.push(key); + }); + + // Apply translations + translations.forEach((translation, sourceText) => { + // 1. Check if sourceText matches any existing value in source locale strings + if (valueToKeys.has(sourceText)) { + const keys = valueToKeys.get(sourceText); + keys?.forEach((key) => { + targetStrings[key] = translation; + }); + } else { + // 2. If not found in strings, assume it might be a direct attribute value (key = value) + // We add it to both source (to normalize) and target + // This covers the case where a button has label="Hello" (no string list entry) + // We effectively promote "Hello" to be a key. + if (!sourceStrings[sourceText]) { + sourceStrings[sourceText] = sourceText; + } + targetStrings[sourceText] = translation; + } + }); + }; + + // Detect format (JSON or ZIP) + const isZip = + (typeof filePathOrBuffer === 'string' && + (filePathOrBuffer.endsWith('.obz') || filePathOrBuffer.endsWith('.zip'))) || + (typeof filePathOrBuffer !== 'string' && + (() => { + const bytes = readBinaryFromInput(filePathOrBuffer); + return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b; + })()); + + if (!isZip) { + // Single OBF file + const content = readTextFromInput(filePathOrBuffer); + const board = JSON.parse(content) as ObfBoard; + processBoardObject(board); + writeTextToPath(outputPath, JSON.stringify(board, null, 2)); + return readBinaryFromInput(outputPath); + } else { + // OBZ Archive + const zipResult = await openZipFromInput(filePathOrBuffer); + const entries = zipResult.zip.listFiles(); + + if (isNodeRuntime()) { + const AdmZip = getNodeRequire()('adm-zip') as typeof import('adm-zip'); + const outZip = new AdmZip(); + + // Copy all files, modifying .obf files + for (const entryName of entries) { + const content = await zipResult.zip.readFile(entryName); + if (entryName.toLowerCase().endsWith('.obf')) { + try { + const board = JSON.parse(decodeText(content)) as ObfBoard; + processBoardObject(board); + outZip.addFile(entryName, Buffer.from(JSON.stringify(board, null, 2), 'utf8')); + } catch (e) { + // Failed to parse, copy as is + outZip.addFile(entryName, Buffer.from(content)); + } + } else { + outZip.addFile(entryName, Buffer.from(content)); + } + } + + const buffer = outZip.toBuffer(); + writeBinaryToPath(outputPath, buffer); + return buffer; + } else { + const module = await import('jszip'); + const JSZip = module.default || module; + const outZip = new JSZip(); + + for (const entryName of entries) { + const content = await zipResult.zip.readFile(entryName); + if (entryName.toLowerCase().endsWith('.obf')) { + try { + const board = JSON.parse(decodeText(content)) as ObfBoard; + processBoardObject(board); + outZip.file(entryName, JSON.stringify(board, null, 2)); + } catch (e) { + outZip.file(entryName, content); + } + } else { + outZip.file(entryName, content); + } + } + + const buffer = await outZip.generateAsync({ type: 'uint8array' }); + writeBinaryToPath(outputPath, buffer); + return buffer; + } + } + } + async saveFromTree(tree: AACTree, outputPath: string): Promise { if (outputPath.endsWith('.obf')) { // Save as single OBF JSON file diff --git a/src/processors/obfsetProcessor.ts b/src/processors/obfsetProcessor.ts index a23a1b9..a3b3621 100644 --- a/src/processors/obfsetProcessor.ts +++ b/src/processors/obfsetProcessor.ts @@ -208,7 +208,8 @@ export class ObfsetProcessor extends BaseProcessor { async processTexts( _filePathOrBuffer: ProcessorInput, _translations: Map, - _outputPath: string + _outputPath: string, + _targetLocale?: string ): Promise { await Promise.resolve(); throw new Error('processTexts is not supported for .obfset currently'); diff --git a/src/processors/opmlProcessor.ts b/src/processors/opmlProcessor.ts index a14e696..d064838 100644 --- a/src/processors/opmlProcessor.ts +++ b/src/processors/opmlProcessor.ts @@ -225,7 +225,8 @@ class OpmlProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { await Promise.resolve(); const content = readTextFromInput(filePathOrBuffer); diff --git a/src/processors/snapProcessor.ts b/src/processors/snapProcessor.ts index 33f8626..97ac45f 100644 --- a/src/processors/snapProcessor.ts +++ b/src/processors/snapProcessor.ts @@ -717,7 +717,8 @@ class SnapProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { if (!isNodeRuntime()) { throw new Error('processTexts is only supported in Node.js environments for Snap files.'); diff --git a/src/processors/touchchatProcessor.ts b/src/processors/touchchatProcessor.ts index 48b0d69..783bfd5 100644 --- a/src/processors/touchchatProcessor.ts +++ b/src/processors/touchchatProcessor.ts @@ -646,7 +646,8 @@ class TouchChatProcessor extends BaseProcessor { async processTexts( filePathOrBuffer: ProcessorInput, translations: Map, - outputPath: string + outputPath: string, + _targetLocale?: string ): Promise { if (!isNodeRuntime()) { throw new Error( diff --git a/test/assets/obf/multilingual.obf b/test/assets/obf/multilingual.obf new file mode 100644 index 0000000..c5d4d93 --- /dev/null +++ b/test/assets/obf/multilingual.obf @@ -0,0 +1,43 @@ +{ + "format": "open-board-0.1", + "id": "multilingual", + "locale": "en", + "name": "Multilingual Board", + "description_html": "Test board with string lists", + "strings": { + "en": { + "happy": "happy", + ":time": "time" + }, + "es": { + "happy": "contento", + ":time": "hora" + }, + "fr": { + ":time": "temps" + } + }, + "buttons": [ + { + "id": "1", + "label": "happy" + }, + { + "id": "2", + "label": ":time" + }, + { + "id": "3", + "label": "Brian" + } + ], + "grid": { + "rows": 1, + "columns": 3, + "order": [ + ["1", "2", "3"] + ] + }, + "images": [], + "sounds": [] +} diff --git a/test/multilingual_support.test.ts b/test/multilingual_support.test.ts new file mode 100644 index 0000000..9cb95ff --- /dev/null +++ b/test/multilingual_support.test.ts @@ -0,0 +1,106 @@ +import { ObfProcessor } from '../src/processors/obfProcessor'; +import { AstericsGridProcessor } from '../src/processors/astericsGridProcessor'; +import path from 'path'; +import fs from 'fs'; + +type AstericsGridJson = { + grids: Array<{ + label?: Record; + gridElements: Array<{ label?: Record }>; + }>; +}; + +describe('Multilingual Support via processTexts', () => { + const tempDir = path.join(__dirname, 'temp_multilingual'); + + beforeAll(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterAll(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('AstericsGridProcessor', () => { + const assetsDir = path.join(__dirname, 'assets/asterics'); + const exampleGrd = path.join(assetsDir, 'example2.grd'); + + it('adds the new locale alongside existing languages', async () => { + const processor = new AstericsGridProcessor(); + const output = path.join(tempDir, 'asterics_multi.grd'); + + const translations = new Map([ + ['Change in element', 'Cambio elemento'], + ['I', 'Io'], + ['You', 'Tu'], + ]); + + await processor.processTexts(exampleGrd, translations, output, 'it'); + + const content = fs.readFileSync(output, 'utf8'); + const json = JSON.parse(content) as AstericsGridJson; + + const grid = json.grids.find((g) => g.label?.en === 'Change in element'); + expect(grid).toBeDefined(); + if (!grid) return; + + expect(grid.label?.it).toBe('Cambio elemento'); + expect(grid.label?.en).toBe('Change in element'); + + const elementI = grid.gridElements.find((el) => el.label?.en === 'I'); + expect(elementI).toBeDefined(); + if (!elementI) return; + + expect(elementI.label?.it).toBe('Io'); + expect(elementI.label?.en).toBe('I'); + + const elementYou = grid.gridElements.find((el) => el.label?.en === 'You'); + expect(elementYou).toBeDefined(); + if (!elementYou) return; + + expect(elementYou.label?.it).toBe('Tu'); + expect(elementYou.label?.en).toBe('You'); + }); + }); + + describe('ObfProcessor', () => { + const assetsDir = path.join(__dirname, 'assets/obf'); + const multiObf = path.join(assetsDir, 'multilingual.obf'); + + it('should add a new language to OBF strings', async () => { + const processor = new ObfProcessor(); + const output = path.join(tempDir, 'obf_multi.obf'); + + // multilingual.obf has en, es, fr. + // en: "happy" -> "happy" + // es: "happy" -> "contento" + + // We want to add 'de' (German). + const translations = new Map(); + translations.set('happy', 'glücklich'); + translations.set('time', 'zeit'); // time is mapped to :time in file + + // Note: In OBF, "happy" is the value for key "happy". + // ":time" is key, "time" is value. + // My implementation maps VALUE to KEYS. + // So 'time' -> matches ':time' -> adds 'de'[':time'] = 'zeit'. + + await processor.processTexts(multiObf, translations, output, 'de'); + + const content = fs.readFileSync(output, 'utf8'); + const json = JSON.parse(content); + + expect(json.strings).toBeDefined(); + expect(json.strings.de).toBeDefined(); + expect(json.strings.de.happy).toBe('glücklich'); + expect(json.strings.de[':time']).toBe('zeit'); + + // Existing shouldn't change + expect(json.strings.en.happy).toBe('happy'); + }); + }); +}); diff --git a/test/obf_multilingual.test.ts b/test/obf_multilingual.test.ts new file mode 100644 index 0000000..ea9acc3 --- /dev/null +++ b/test/obf_multilingual.test.ts @@ -0,0 +1,74 @@ +import { ObfProcessor } from '../src/processors/obfProcessor'; +// import { AACButton } from '../src/core/treeStructure'; +import path from 'path'; +import fs from 'fs'; + +describe('ObfProcessor Multilingual Support', () => { + const assetsDir = path.join(__dirname, 'assets/obf'); + const multilingualObfPath = path.join(assetsDir, 'multilingual.obf'); + + it('should resolve strings based on default locale (en)', async () => { + const processor = new ObfProcessor(); + const tree = await processor.loadIntoTree(multilingualObfPath); + const page = Object.values(tree.pages)[0]; + + // Check buttons + const btnHappy = page.buttons.find((b) => b.id.endsWith('::1')); + const btnTime = page.buttons.find((b) => b.id.endsWith('::2')); + const btnBrian = page.buttons.find((b) => b.id.endsWith('::3')); + + // "happy" -> "happy" (in strings.en) + expect(btnHappy?.label).toBe('happy'); + + // ":time" -> "time" (in strings.en) + expect(btnTime?.label).toBe('time'); + + // "Brian" -> "Brian" (fallback to attribute as-is) + expect(btnBrian?.label).toBe('Brian'); + }); + + it('should resolve strings based on locale "es"', async () => { + const processor = new ObfProcessor(); + + // Read and modify locale to 'es' + const content = fs.readFileSync(multilingualObfPath, 'utf8'); + const obfData = JSON.parse(content); + obfData.locale = 'es'; + + const tree = await processor.loadIntoTree(JSON.stringify(obfData)); + const page = Object.values(tree.pages)[0]; + + const btnHappy = page.buttons.find((b) => b.id.endsWith('::1')); + const btnTime = page.buttons.find((b) => b.id.endsWith('::2')); + const btnBrian = page.buttons.find((b) => b.id.endsWith('::3')); + + // "happy" -> "contento" (in strings.es) + expect(btnHappy?.label).toBe('contento'); + + // ":time" -> "hora" (in strings.es) + expect(btnTime?.label).toBe('hora'); + + // "Brian" -> "Brian" (fallback, not in strings.es) + expect(btnBrian?.label).toBe('Brian'); + }); + + it('should resolve strings based on locale "fr" (partial)', async () => { + const processor = new ObfProcessor(); + + const content = fs.readFileSync(multilingualObfPath, 'utf8'); + const obfData = JSON.parse(content); + obfData.locale = 'fr'; + + const tree = await processor.loadIntoTree(JSON.stringify(obfData)); + const page = Object.values(tree.pages)[0]; + + const btnHappy = page.buttons.find((b) => b.id.endsWith('::1')); + const btnTime = page.buttons.find((b) => b.id.endsWith('::2')); + + // "happy" -> "happy" (fallback to attribute, as not in strings.fr) + expect(btnHappy?.label).toBe('happy'); + + // ":time" -> "temps" (in strings.fr) + expect(btnTime?.label).toBe('temps'); + }); +});