import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url)) const ts = requireWeb('typescript') const features = new URL('../../unreal_tran_web/src/features/', import.meta.url) const compileText = source => ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, }).outputText const compile = path => compileText(readFileSync(new URL(path, features), 'utf8')) const dataUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` const documentUrl = dataUrl(compile('training/trainingDocument.ts')) const bridgeUrl = dataUrl(compile('training-legacy/bridge.ts').replace(/from ['"]\.\.\/training\/trainingDocument['"]/, `from '${documentUrl}'`)) const { toFormalContent, toLegacyProject } = await import(bridgeUrl) const { normalizeTrainingContent } = await import(documentUrl) const project = content => ({ id: 'compat-test', name: 'Compatibility', type: 'TRAINING', trainingMode: 'VIRTUAL', status: 'DRAFT', content }) const save = (legacy, previous) => toFormalContent(legacy, 'VIRTUAL', previous).content const sceneObjects = ['A', 'B'].map(id => ({ id, sceneId: id, name: `Target ${id}`, visible: true, semantic: { operable: true, interactionId: `scene:${id}`, capabilities: ['inspect'], deviceId: `DEV-${id}` }, })) for (const runtime of ['guide-legacy/demo-src', 'virtual-training-legacy/upstream']) { const { createTrainingProject, normalizeTrainingProject } = await import(new URL(`${runtime}/training-project-io.js`, features)) const fixture = () => { const value = createTrainingProject({ trainingMode: 'virtual' }) value.steps = value.steps.slice(0, 1) return value } const reopen = content => normalizeTrainingProject(toLegacyProject(project(content))) test(`${runtime}: legacy operation text is visible to the formal editor and survives reopen`, () => { const value = fixture() value.steps[0].instruction = '确认狐狸目标位置并记录观察结果' value.steps[0].guidance = '使用当前镜头观察目标' const content = save(value) assert.equal(content.steps[0].summary, value.steps[0].instruction) assert.equal(content.steps[0].guidance, value.steps[0].guidance) assert.equal(reopen(content).steps[0].instruction, value.steps[0].instruction) }) test(`${runtime}: distinct existing summary and instruction survive an unchanged roundtrip`, () => { const content = save(fixture()) Object.assign(content.steps[0], { summary: '正式已有说明', instruction: '已有详细说明', guidance: '已有引导' }) const original = structuredClone(content) const next = save(reopen(content), content) for (const key of ['summary', 'instruction', 'guidance']) assert.equal(next.steps[0][key], content.steps[0][key]) assert.deepEqual(content, original) }) test(`${runtime}: summary-only historical documents open with their real operation text`, () => { const content = save(fixture()) content.steps[0].summary = '正式说明用于旧编辑器显示' for (const instruction of [undefined, '']) { if (instruction === undefined) delete content.steps[0].instruction else content.steps[0].instruction = instruction const reopened = reopen(content) assert.equal(reopened.steps[0].instruction, content.steps[0].summary) assert.equal(save(reopened, content).steps[0].summary, content.steps[0].summary) } }) test(`${runtime}: clearing operation and optional guidance does not restore demonstration text`, () => { const content = save(fixture()) const fields = ['summary', 'instruction', 'guidance', 'warning', 'destination', 'success'] for (const key of fields) content.steps[0][key] = '' const reopened = reopen(content) const next = save(reopened, content) for (const key of fields) { assert.equal(reopened.steps[0][key], '', key) assert.equal(next.steps[0][key], '', key) } delete content.steps[0].instruction assert.equal(reopen(content).steps[0].instruction, '') }) test(`${runtime}: formally selected target wins over stale legacy references`, () => { const source = fixture() source.scenario.publishedSceneId = 'scene-test' source.scenario.sceneResource = { sceneId: 'scene-test', snapshot: { objects: sceneObjects } } Object.assign(source.steps[0], { targetId: 'scene:A', targetRef: { interactionId: 'scene:A' }, mode: 'inspect', targetNodeId: 'scene:A' }) const content = save(normalizeTrainingProject(source)) Object.assign(content.steps[0], { targetId: 'scene:B', targetName: 'Target B' }) content.steps[0].action.targetId = 'scene:B' const next = save(reopen(content), content) assert.equal(next.steps[0].targetId, 'scene:B') assert.equal(next.steps[0].action.targetId, 'scene:B') assert.equal(next.steps[0].targetRef.interactionId, 'scene:B') assert.equal(next.steps[0].targetRef.sceneObjectId, 'B') assert.equal(next.steps[0].targetRef.deviceId, 'DEV-B') assert.equal(next.steps[0].targetNodeId, 'scene:B') assert.equal(next.steps[0].targetNodeName, 'Target B') assert.equal(content.steps[0].targetRef.interactionId, 'scene:A') }) test(`${runtime}: an explicitly cleared or missing target is not replaced by its stale reference`, () => { const source = fixture() source.scenario.publishedSceneId = 'scene-test' source.scenario.sceneResource = { sceneId: 'scene-test', snapshot: { objects: sceneObjects } } for (const id of ['', 'scene:missing']) { Object.assign(source.steps[0], { targetId: id, targetRef: { interactionId: 'scene:A' }, mode: 'inspect' }) const normalized = normalizeTrainingProject(source) assert.equal(normalized.steps[0].targetId, id) assert.equal(normalized.steps[0].targetRef, null) assert.equal(normalized.steps[0].action.targetId, id) } }) } test('formal normalizer migrates old operation text while preserving explicit clears and distinct text', () => { const content = { steps: [ { id: 'instruction-only', instruction: '旧说明' }, { id: 'empty-summary', summary: '', instruction: '旧说明二' }, { id: 'both-empty', summary: '', instruction: '' }, { id: 'distinct', summary: '简述', instruction: '详情' }, ] } const next = normalizeTrainingContent(content, 'VIRTUAL') assert.deepEqual(next.steps.map(step => [step.summary, step.instruction]), [ ['旧说明', '旧说明'], ['旧说明二', '旧说明二'], ['', ''], ['简述', '详情'], ]) }) test('old local buffers retain saved summary unless the operation instruction was edited', () => { const content = normalizeTrainingContent({ steps: [{ id: 'step-a', summary: '正式说明', instruction: '详细说明' }] }, 'VIRTUAL') const unchanged = { steps: [{ id: 'step-a', instruction: '详细说明' }] } assert.equal(save(unchanged, content).steps[0].summary, '正式说明') const changed = { steps: [{ id: 'step-a', instruction: '实际编辑新说明' }] } assert.equal(save(changed, content).steps[0].summary, '实际编辑新说明') }) test('actual legacy input handler synchronizes operation text only when that field is edited', () => { const source = readFileSync(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8') const start = source.indexOf(' updateField(input, { render = false } = {}) {') const end = source.indexOf('\n onDragStart(', start) assert.ok(start >= 0 && end > start) const editor = new Function('setPath', `return ({${source.slice(start, end)}})`)((object, key, value) => { object[key] = value }) editor.selectedStep = { id: 'test', summary: '原正式说明', instruction: '原详细说明' } editor.project = { id: 'test' } editor.markDirty = () => {} editor.updateField({ dataset: { scope: 'step', field: 'guidance' }, type: 'text', value: '新引导' }) assert.equal(editor.selectedStep.summary, '原正式说明') for (const value of ['新的操作说明', '']) { editor.updateField({ dataset: { scope: 'step', field: 'instruction' }, type: 'text', value }) assert.equal(editor.selectedStep.summary, value) assert.equal(editor.selectedStep.instruction, value) } }) const studio = readFileSync(new URL('../views/content/TrainingStudioView.vue', features), 'utf8') test('actual studio description handler synchronizes both document fields including clear', () => { const start = studio.indexOf('const setStepSummary =') const end = studio.indexOf('const selectTarget =', start) assert.ok(start >= 0 && end > start) const selected = { value: { summary: 'old', instruction: 'different' } } const handler = new Function('selectedStep', 'markDirty', `${compileText(studio.slice(start, end))}; return setStepSummary`)(selected, () => {}) for (const value of ['正式新操作说明', '']) { handler(value) assert.equal(selected.value.summary, value) assert.equal(selected.value.instruction, value) } assert.match(studio, /@update:model-value="setStepSummary"/) }) test('actual studio target selection synchronizes semantic, node and action references', () => { const start = studio.indexOf('const selectTarget =') const end = studio.indexOf('const selectMode =', start) assert.ok(start >= 0 && end > start) const selected = { value: { targetId: 'scene:A', targetRef: { interactionId: 'scene:A' }, mode: 'inspect', action: { targetId: 'scene:A' }, toolPlacement: { attachedTargetId: 'scene:A', pose: 'target', position: [1, 2, 3] } } } const catalog = { value: new Map([['scene:B', { id: 'scene:B', label: 'Target B', modes: ['inspect'] }]]) } const handler = new Function('selectedStep', 'targetById', 'sceneContent', 'markDirty', 'selectMode', `${compileText(studio.slice(start, end))}; return selectTarget`)( selected, catalog, { value: { objects: sceneObjects } }, () => {}, () => { throw new Error('Unexpected mode change') }, ) handler('scene:B') assert.equal(selected.value.targetId, 'scene:B') assert.equal(selected.value.action.targetId, 'scene:B') assert.equal(selected.value.targetRef.interactionId, 'scene:B') assert.equal(selected.value.targetRef.sceneObjectId, 'B') assert.equal(selected.value.targetRef.deviceId, 'DEV-B') assert.equal(selected.value.targetNodeId, 'scene:B') assert.equal(selected.value.targetNodeName, 'Target B') assert.equal(selected.value.toolPlacement.attachedTargetId, '') assert.deepEqual(selected.value.toolPlacement.position, [1, 2, 3]) })