feat: add Korean-English localization and light theme

Localize studio, curriculum, visualization and loading interfaces. Add persistent language and theme settings, refine module loading states, and normalize remote shell locales.
This commit is contained in:
2026-09-29 13:27:11 +09:00
parent 4125f71792
commit d44180db67
74 changed files with 2519 additions and 998 deletions
+209
View File
@@ -0,0 +1,209 @@
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('<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)
})