const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const { test } = require('node:test') const ts = require('typescript') const { createInstance } = require('i18next') const root = path.resolve(__dirname, '../src/renderer/src') const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8')) test('all execution guide page titles have English translations', () => { const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8') const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1]) assert.equal(titles.length, 4) for (const title of titles) assert.ok(dictionary[title], title) }) test('visualization progress uses exact runtime translation keys', () => { const { api } = setup('en') assert.equal(api.t('맵 환경 준비 중'), 'Preparing map environment') assert.equal(api.t('시작 중'), 'Starting') assert.equal(api.t('종료 중'), 'Stopping') const panel = fs.readFileSync(path.join(root, 'features/workspace/WorkspaceVisualizationPanel.tsx'), 'utf8') assert.ok(panel.includes('busy ? t(statusMessage)')) }) test('viewer translates before 3D initialization and accepts language only from its parent', () => { const events = new Map() const sent = [] const parent = { postMessage: message => sent.push(message) } const label = { dataset: { i18n: '응답을 기다리는 중입니다.' }, textContent: '' } const document = { documentElement: {}, querySelectorAll: selector => selector === '[data-i18n]' ? [label] : [] } const source = fs.readFileSync(path.join(root, '../public/slam-viewer/locale.js'), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, allowJs: true } }).outputText vm.runInNewContext(code, { exports: {}, document, localStorage: { getItem: () => 'ko' }, window: { parent, addEventListener: (type, listener) => events.set(type, listener) } }) assert.equal(sent[0].type, 'lms:language:ready') events.get('message')({ source: {}, data: { type: 'lms:language', language: 'en' } }) assert.equal(label.textContent, '응답을 기다리는 중입니다.') events.get('message')({ source: parent, data: { type: 'lms:language', language: 'en' } }) assert.equal(label.textContent, 'Waiting for a response.') events.get('message')({ source: parent, data: { type: 'lms:language', language: 'ko' } }) assert.equal(label.textContent, '응답을 기다리는 중입니다.') const html = fs.readFileSync(path.join(root, '../public/slam-viewer/index.html'), 'utf8') assert.ok(html.includes('')) }) function setup(savedLanguage, storageBlocked = false) { const instance = createInstance() const storage = new Map([['autonomy-studio.lms.language', savedLanguage]]) const events = new Map() const document = { documentElement: { lang: '' } } const exports = {} const source = fs.readFileSync(path.join(root, 'i18n/index.ts'), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText vm.runInNewContext(code, { exports, document, localStorage: { getItem(key) { if (storageBlocked) throw Error('blocked'); return storage.get(key) }, setItem(key, value) { if (storageBlocked) throw Error('blocked'); storage.set(key, value) } }, window: { addEventListener: (name, listener) => events.set(name, listener) }, require(name) { if (name === 'i18next') return instance if (name === './en.json') return dictionary return require(name) } }) return { api: exports, storage, document, events } } test('Korean defaults, English persists, interpolation and fallback preserve user content', () => { const { api, storage, document } = setup('invalid') assert.equal(api.t('설정'), '설정') assert.equal(document.documentElement.lang, 'ko') api.setLanguage('en') assert.equal(api.t('설정'), 'Settings') assert.equal(api.t('{{week}}주차', { week: 3 }), 'Week 3') assert.equal(api.t('강사가 작성한 강좌 제목'), '강사가 작성한 강좌 제목') assert.equal(api.t('sim_mode:=false'), 'sim_mode:=false') assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'en') assert.equal(document.documentElement.lang, 'en') api.setLanguage('ko') assert.equal(api.t('{{week}}주차', { week: 3 }), '3주차') }) test('restores preference and synchronizes other renderer windows', () => { const { api, document, events } = setup('en') assert.equal(api.t('설정'), 'Settings') events.get('storage')({ key: api.LANGUAGE_STORAGE_KEY, newValue: 'ko' }) assert.equal(document.documentElement.lang, 'ko') }) test('blocked storage does not prevent switching language', () => { const { api } = setup(undefined, true) api.setLanguage('en') assert.equal(api.t('설정'), 'Settings') }) test('language selector edits a draft without applying or persisting it', () => { const { api, storage, document } = setup('ko') const exports = {} const source = fs.readFileSync(path.join(root, 'i18n/LanguageSelect.tsx'), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText vm.runInNewContext(code, { exports, require: name => name === './index' ? api : require(name) }) let draft = 'ko' const element = exports.default({ dark: false, value: draft, onChange: value => { draft = value } }) const select = element.props.children.find(child => child.type === 'select') select.props.onChange({ target: { value: 'en' } }) assert.equal(draft, 'en') assert.equal(document.documentElement.lang, 'ko') assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'ko') }) test('built-in map labels follow language without changing map data or saved names', () => { const { api } = setup('ko') const exports = {} const source = fs.readFileSync(path.join(root, 'features/workspace/WorldMapSelectorModal.tsx'), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText vm.runInNewContext(code, { exports, require: name => { if (name === '../../i18n') return api if (name === './studioTheme') return { useStudioTheme: () => 'dark' } if (name.endsWith('.css')) return {} return require(name) } }) const map = exports.NAV2_MAP_OPTIONS[0] assert.equal(exports.getMapDisplayName(map), '기본 월드') api.setLanguage('en') assert.equal(exports.getMapDisplayName(map), 'Default world') assert.equal(exports.getMapDisplayName({ ...map, saved: true }), '기본 월드') assert.equal(map.name, '기본 월드') assert.equal(map.worldFile, 'turtlebot3_world.world') assert.equal(api.t('새 2D 맵'), 'New 2D map') assert.equal(api.t('새 3D 맵'), 'New 3D map') }) test('translations preserve every interpolation placeholder', () => { const placeholders = value => [...value.matchAll(/{{\s*([^}]+)\s*}}/g)].map(match => match[1]).sort() for (const [key, value] of Object.entries(dictionary)) { assert.ok(value.trim(), `Empty translation: ${key}`) assert.deepEqual(placeholders(value), placeholders(key), key) } }) test('learning statistics translate API labels but preserve numeric values', () => { const { api } = setup('ko') const exports = {} const source = fs.readFileSync(path.join(root, 'features/curriculum/LearningStatsCard.tsx'), 'utf8') const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText vm.runInNewContext(code, { exports, require: name => name === '../../i18n' ? { ...api, useLocaleRender() {} } : require(name) }) const stats = [ { key: 'weeks', label: '완료한 주차', value: '0/3', iconKey: 'check' }, { key: 'time', label: '학습 시간', value: '02:08', iconKey: 'clock' }, { key: 'commits', label: '커밋 수', value: '0', iconKey: 'trending' }, { key: 'score', label: '평균 점수', value: '0', iconKey: 'gauge' } ] const { createElement } = require('react') const { renderToStaticMarkup } = require('react-dom/server') const render = () => renderToStaticMarkup(createElement(exports.default, { stats })) assert.ok(render().includes('완료한 주차')) api.setLanguage('en') const html = render() for (const text of ['Completed weeks', 'Learning time', 'Commits', 'Average score', '0/3', '02:08']) { assert.ok(html.includes(text), text) } for (const item of stats) assert.ok(!html.includes(item.label), item.label) api.setLanguage('ko') assert.ok(render().includes('평균 점수')) }) test('all literal translation calls have an English entry', () => { function check(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const file = path.join(dir, entry.name) if (entry.isDirectory()) { check(file); continue } if (!/\.tsx?$/.test(file)) continue const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) function visit(node) { if (ts.isCallExpression(node) && node.expression.getText(source) === 't' && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) { assert.ok(Object.hasOwn(dictionary, node.arguments[0].text), `${file}: ${node.arguments[0].text}`) } ts.forEachChild(node, visit) } visit(source) } } check(root) })