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:
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
const { readFileSync, mkdtempSync, writeFileSync, rmSync } = require('node:fs')
|
||||
const { tmpdir } = require('node:os')
|
||||
const { join } = require('node:path')
|
||||
const { spawn } = require('node:child_process')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
const transpile = (source) => ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||
}).outputText
|
||||
const helpers = { exports: {} }
|
||||
vm.runInNewContext(transpile(readFileSync(join(__dirname, '../src/main/remoteShell.ts'), 'utf8')), helpers)
|
||||
const { remoteShellCommand, remoteShellEnvironment } = helpers.exports
|
||||
const source = readFileSync(join(__dirname, '../src/main/index.ts'), 'utf8')
|
||||
const start = source.indexOf('async function runCommandOutput(')
|
||||
const end = source.indexOf('\nasync function runCommand(', start)
|
||||
const context = { spawn, process, setTimeout, clearTimeout, createModuleProgressParser: () => () => {} }
|
||||
vm.runInNewContext(transpile(source.slice(start, end)), context)
|
||||
const run = context.runCommandOutput
|
||||
|
||||
test('SSH data output excludes locale warnings even when command succeeds', async () => {
|
||||
const result = await run('/bin/bash', ['--noprofile', '--norc', '-c', 'printf %s "/tmp/module with spaces"'], undefined,
|
||||
{ LC_ALL: 'missing_TEST.UTF-8' }, 5000, 'stdout')
|
||||
assert.equal(result, '/tmp/module with spaces')
|
||||
})
|
||||
|
||||
test('failed commands retain stderr diagnostics and stdout', async () => {
|
||||
await assert.rejects(run('/bin/sh', ['-c', 'printf context; printf "permission denied" >&2; exit 7'], undefined, undefined, 5000, 'stdout'),
|
||||
error => error.message.includes('context') && error.message.includes('permission denied'))
|
||||
})
|
||||
|
||||
test('non-SSH combined output stays compatible and explicit log merging works', async () => {
|
||||
const command = 'printf output; printf diagnostic >&2'
|
||||
const combined = await run('/bin/sh', ['-c', command])
|
||||
assert.match(combined, /output/)
|
||||
assert.match(combined, /diagnostic/)
|
||||
const logs = await run('/bin/sh', ['-c', `{ ${command}; } 2>&1`], undefined, undefined, 5000, 'stdout')
|
||||
assert.match(logs, /diagnostic/)
|
||||
})
|
||||
|
||||
test('SSH environment neutralizes locale forwarding without losing other settings', () => {
|
||||
const original = { LANG: 'ko_KR.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'bad', LANGUAGE: 'ko:en', PATH: '/bin', TERM: 'xterm-256color' }
|
||||
const env = remoteShellEnvironment(original)
|
||||
assert.equal(env.LANG, 'C')
|
||||
assert.equal(env.LC_ALL, 'C')
|
||||
assert.equal(env.LC_CTYPE, undefined)
|
||||
assert.equal(env.LANGUAGE, undefined)
|
||||
assert.equal(env.PATH, '/bin')
|
||||
assert.equal(env.TERM, 'xterm-256color')
|
||||
assert.equal(original.LC_CTYPE, 'bad')
|
||||
})
|
||||
|
||||
test('remote wrapper preserves multiline commands, Unicode, quotes and exit status', async () => {
|
||||
const command = `cat <<'END'
|
||||
/tmp/한글 module's folder
|
||||
END`
|
||||
const result = await run('/bin/sh', ['-c', remoteShellCommand(command)], undefined, remoteShellEnvironment(process.env), 5000, 'stdout')
|
||||
assert.equal(result, "/tmp/한글 module's folder\n")
|
||||
await assert.rejects(run('/bin/sh', ['-c', remoteShellCommand('echo remote-failure >&2; exit 9')], undefined, undefined, 5000, 'stdout'), /remote-failure/)
|
||||
})
|
||||
|
||||
test('locale selection prefers C UTF-8, supports other UTF-8 locales and falls back to C', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'lms-locale-test-'))
|
||||
try {
|
||||
for (const [locales, expected] of [['en_GB.utf8 C.utf8', 'C.utf8'], ['C en_GB.utf8', 'en_GB.utf8'], ['C POSIX', 'C']]) {
|
||||
writeFileSync(join(dir, 'locale'), `#!/bin/sh\nprintf '%s\\n' ${locales}\n`, { mode: 0o755 })
|
||||
const result = await run('/bin/sh', ['-c', remoteShellCommand('printf "%s|%s" "$LANG" "$LC_ALL"')], undefined,
|
||||
{ PATH: `${dir}:${process.env.PATH}`, LC_ALL: 'C' }, 5000, 'stdout')
|
||||
assert.equal(result, `${expected}|${expected}`)
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test('queued SSH, stats probes and remote PTY use normalization; container logs keep stderr', () => {
|
||||
const ssh = source.slice(source.indexOf('async function runSshOutput('), source.indexOf('async function readRemoteDockerStatsOutput('))
|
||||
assert.equal((ssh.match(/remoteShellCommand\(command\)/g) || []).length, 2)
|
||||
assert.equal((ssh.match(/'stdout'/g) || []).length, 2)
|
||||
const terminal = source.slice(source.indexOf("ipcMain.handle('remote-workspace:terminal:start'"), source.indexOf("ipcMain.handle('terminal-resize'"))
|
||||
assert.match(terminal, /remoteShellCommand\(mode === 'container'/)
|
||||
assert.match(terminal, /remoteShellEnvironment\(terminalEnvironment/)
|
||||
assert.match(source, /target \? remoteShellCommand\(init\)/)
|
||||
assert.match(source, /docker logs --timestamps --tail 300.*2>&1/)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const root = path.resolve(__dirname, '../src/renderer/src/features/workspace')
|
||||
|
||||
function setup(saved, blocked = false) {
|
||||
const storage = new Map([['autonomy-studio.lms.studio-theme', saved]])
|
||||
const events = new Map()
|
||||
const exports = {}
|
||||
const code = ts.transpileModule(fs.readFileSync(path.join(root, 'studioTheme.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText
|
||||
vm.runInNewContext(code, { exports,
|
||||
require: () => ({ useSyncExternalStore: (_subscribe, snapshot) => snapshot() }),
|
||||
localStorage: {
|
||||
getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) },
|
||||
setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) }
|
||||
},
|
||||
window: { addEventListener: (type, listener) => events.set(type, listener) }
|
||||
})
|
||||
return { api: exports, storage, events }
|
||||
}
|
||||
|
||||
test('theme defaults dark, persists light, and restores the preference', () => {
|
||||
const { api, storage } = setup('invalid')
|
||||
assert.equal(api.useStudioTheme(), 'dark')
|
||||
api.setStudioTheme('light')
|
||||
assert.equal(api.useStudioTheme(), 'light')
|
||||
assert.equal(storage.get(api.STUDIO_THEME_KEY), 'light')
|
||||
assert.equal(setup('light').api.useStudioTheme(), 'light')
|
||||
})
|
||||
test('theme remains usable when storage is blocked', () => {
|
||||
const { api } = setup(null, true)
|
||||
api.setStudioTheme('light')
|
||||
assert.equal(api.useStudioTheme(), 'light')
|
||||
})
|
||||
test('theme synchronizes across windows without writing runtime settings', () => {
|
||||
const { api, storage, events } = setup('dark')
|
||||
storage.set(api.STUDIO_THEME_KEY, 'light')
|
||||
events.get('storage')({ key: api.STUDIO_THEME_KEY })
|
||||
assert.equal(api.useStudioTheme(), 'light')
|
||||
storage.clear()
|
||||
events.get('storage')({ key: null })
|
||||
assert.equal(api.useStudioTheme(), 'dark')
|
||||
})
|
||||
Reference in New Issue
Block a user