Files
lms-fe/scripts/test-i18n.cjs
T
2026-09-29 16:53:20 +09:00

266 lines
13 KiB
JavaScript

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('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
assert.ok(expression)
for (const language of ['en', 'ko']) {
const { api } = setup(language)
const title = vm.runInNewContext(expression, {
runningPlatformCloseTarget: { platformKey: 'futuredrive-local-pc', title: '로컬 PC' }, t: api.t
})
assert.equal(title, language === 'en' ? 'Local PC' : '로컬 PC')
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오를 종료할까요?', { v0: title }), 'Close the Local PC studio?')
assert.equal(vm.runInNewContext(expression, {
runningPlatformCloseTarget: { platformKey: 'vehicle', title: '교육용 차량' }, t: api.t
}), '교육용 차량')
}
})
test('session confirmation uses a localized platform display name', () => {
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
const expression = source.match(/const activePlatformDisplayName = ([\s\S]*?)\n const sessionPlatformLabel/)[1]
for (const language of ['en', 'ko']) {
const { api } = setup(language)
const name = vm.runInNewContext(expression, {
workspacePlatform: null,
sessionPlatform: { platformKey: 'futuredrive-local-pc' },
activePlatformName: '로컬 PC', t: api.t
})
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', { v0: name }),
'Disconnect from the Local PC studio only. Platform control is retained.')
assert.equal(vm.runInNewContext(expression, {
workspacePlatform: { platformKey: 'vehicle' }, sessionPlatform: null,
activePlatformName: '교육용 차량', t: api.t
}), '교육용 차량')
}
assert.match(source, /<SessionConfirmModal[\s\S]*?platformName=\{activePlatformDisplayName\}/)
})
test('close and disconnect modals localize raw Local PC names at render time', () => {
const source = fs.readFileSync(path.join(root, 'components/AppStatusModals.tsx'), 'utf8')
for (const [key, variable] of [
['{{v0}} 개발 스튜디오를 종료할까요?', 'platformTitle'],
['{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', 'platformName']
]) {
const line = source.split('\n').find(line => line.includes(key))
const expression = line.match(/v0: (.*?) \}/)[1]
for (const language of ['ko', 'en']) {
const { api } = setup(language)
const name = vm.runInNewContext(expression, { [variable]: '로컬 PC', t: api.t })
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
if (language === 'en') assert.ok(!api.t(key, { v0: name }).includes('로컬'))
assert.equal(vm.runInNewContext(expression, { [variable]: 'Yahboom Micro #02', t: api.t }), 'Yahboom Micro #02')
}
}
})
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('<script type="module" src="./locale.js"></script>'))
})
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)
})