d44180db67
Localize studio, curriculum, visualization and loading interfaces. Add persistent language and theme settings, refine module loading states, and normalize remote shell locales.
210 lines
9.7 KiB
JavaScript
210 lines
9.7 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('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)
|
|
})
|