import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import test from 'node:test' import ts from 'typescript' const features = new URL('../../unreal_tran_web/src/features/', import.meta.url) async function moduleUrl(relative, replacements = {}) { let source = await readFile(new URL(relative, features), 'utf8') for (const [from, to] of Object.entries(replacements)) source = source.replace(from, to) const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }) return `data:text/javascript;base64,${Buffer.from(output.outputText).toString('base64')}` } const sceneCaptureUrl = await moduleUrl('scene-legacy/sceneCoverCapture.ts') const { captureTrainingCover } = await import(await moduleUrl('training-legacy/trainingCoverCapture.ts', { '../scene-legacy/sceneCoverCapture': sceneCaptureUrl, })) const { saveTrainingWithCover } = await import(await moduleUrl('training-legacy/trainingCoverPersistence.ts')) const oldCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://old', sortOrder: 1 } const audio = { code: 'NARRATION', storageUri: 'fsvc://audio', sortOrder: 0 } const newCover = { code: 'TRAINING_COVER', storageUri: 'fsvc://new', sortOrder: 1, uploadTicket: 'test-only-ticket' } function persistenceFixture({ captureError, uploadError, updateError, invalidateAfterUpload = false } = {}) { const calls = [] let current = true const project = { id: '161', version: 8, coverUri: oldCover.storageUri, assets: [audio, oldCover] } const input = { type: 'TRAINING', version: 8, assets: [audio, oldCover], content: { steps: [{ stepCode: 'STEP-1' }] }, dependencies: [{ targetProjectId: '160', targetVersionId: '210', relationType: 'TRAINING_SCENE', required: true }] } const capture = async () => { if (captureError) throw captureError; return new Blob(['jpeg'], { type: 'image/jpeg' }) } const api = { async upload(file, params) { calls.push({ method: 'upload', file, params }); if (uploadError) throw uploadError; if (invalidateAfterUpload) current = false; return newCover }, async update(body) { calls.push({ method: 'PUT', body }); if (updateError) throw updateError; return { ...project, ...body, version: 9 } }, } const run = () => saveTrainingWithCover(project, input, capture, api, () => { if (!current) throw new Error('Project switched') }) return { calls, project, input, run } } test('training cover ticket, content and fixed scene dependency share exactly one PUT', async () => { const f = persistenceFixture() const result = await f.run() assert.equal(result.coverFailed, false) assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT']) assert.equal(f.calls[0].file.type, 'image/jpeg') assert.equal(f.calls[0].params.projectVersion, 8) assert.equal(f.calls[0].params.code, 'TRAINING_COVER') assert.deepEqual(f.calls[1].body.assets, [audio, newCover]) assert.equal(f.calls[1].body.coverUri, 'fsvc://new') assert.deepEqual(f.calls[1].body.dependencies, f.input.dependencies) assert.deepEqual(f.calls[1].body.content, f.input.content) assert.deepEqual(f.input.assets, [audio, oldCover]) }) for (const failure of ['captureError', 'uploadError']) { test(`${failure} retains the old cover while saving training content`, async () => { const f = persistenceFixture({ [failure]: new Error('Unavailable') }) const result = await f.run() assert.equal(result.coverFailed, true) const puts = f.calls.filter(x => x.method === 'PUT') assert.equal(puts.length, 1) assert.equal(puts[0].body.coverUri, 'fsvc://old') assert.deepEqual(puts[0].body.assets, [audio, oldCover]) }) } test('upload 409 stops without attempting a fallback PUT', async () => { const conflict = Object.assign(new Error('Conflict'), { status: 409 }) const f = persistenceFixture({ uploadError: conflict }) await assert.rejects(f.run(), error => error === conflict) assert.deepEqual(f.calls.map(x => x.method), ['upload']) }) test('PUT 409 is propagated without retrying or replacing the prior cover', async () => { const conflict = Object.assign(new Error('Conflict'), { status: 409 }) const f = persistenceFixture({ updateError: conflict }) await assert.rejects(f.run(), error => error === conflict) assert.deepEqual(f.calls.map(x => x.method), ['upload', 'PUT']) assert.equal(f.project.coverUri, oldCover.storageUri) }) test('project switch while uploading stops the final update', async () => { const f = persistenceFixture({ invalidateAfterUpload: true }) await assert.rejects(f.run(), /Project switched/) assert.deepEqual(f.calls.map(x => x.method), ['upload']) }) const value = initial => ({ value: initial, clone() { return value(this.value) }, copy(other) { this.value = other.value; return this } }) function captureFixture({ drawError } = {}) { const grid = { type: 'GridHelper', visible: true }, path = { visible: true } const material = { emissive: value('selected'), emissiveIntensity: 0.55 } const object = { scale: value(1.04) } const events = [] const record = action => events.push({ action, grid: grid.visible, path: path.visible, color: material.emissive.value, intensity: material.emissiveIntensity, scale: object.scale.value }) const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() { record('copy'); if (drawError) throw drawError } }), toBlob(callback, type) { queueMicrotask(() => callback(new Blob(['image'], { type }))) } } const editor = { scene: { traverse: visitor => visitor(grid) }, camera: {}, pathPreview: path, targetMaterials: new Map([['fox', [{ material, color: value('original'), intensity: 0 }]]]), targetScales: new Map([['fox', value(1)]]), targetObjects: new Map([['fox', object]]), renderer: { domElement: { width: 1600, height: 900 }, render() { record('render') } }, } const options = { document: { createElement: () => canvas } } const restored = () => { assert.equal(grid.visible, true); assert.equal(path.visible, true) assert.equal(material.emissive.value, 'selected'); assert.equal(material.emissiveIntensity, 0.55) assert.equal(object.scale.value, 1.04) } return { editor, options, canvas, events, restored } } test('capture omits training selection glow, scale and helpers, preserving the current camera', async () => { const f = captureFixture() const camera = f.editor.camera const image = await captureTrainingCover(f.editor, f.options) const copied = f.events.find(event => event.action === 'copy') assert.deepEqual(copied, { action: 'copy', grid: false, path: false, color: 'original', intensity: 0, scale: 1 }) f.restored() assert.equal(f.editor.camera, camera) assert.equal(f.canvas.width, 640); assert.equal(f.canvas.height, 360) assert.equal(image.type, 'image/jpeg') }) test('capture failures restore all transient training presentation state', async () => { const f = captureFixture({ drawError: new Error('Canvas blocked') }) await assert.rejects(captureTrainingCover(f.editor, f.options), /Canvas blocked/) f.restored() }) test('loading, failed and disposed training viewports cannot overwrite the cover with a placeholder', async () => { for (const state of ['loading', 'error']) { const f = captureFixture(); f.editor.container = { dataset: { state } } await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/) assert.equal(f.events.length, 0) } const f = captureFixture(); f.editor.disposed = true await assert.rejects(captureTrainingCover(f.editor, f.options), /尚未加载完成/) const hidden = captureFixture(); hidden.editor.renderer.domElement.width = 2 await assert.rejects(captureTrainingCover(hidden.editor, hidden.options), /尚未加载完成/) }) // Exercise the actual vendored methods with a minimal host, avoiding a mock reimplementation. const editorSource = await readFile(new URL('guide-legacy/demo-src/training-editor.js', features), 'utf8') const saveMethod = editorSource.slice(editorSource.indexOf(' async saveDraft('), editorSource.indexOf(' showValidation()')) const publishMethod = editorSource.slice(editorSource.indexOf(' async publishProject('), editorSource.indexOf(' async runProject()')) const bindMethod = editorSource.slice(editorSource.indexOf(' async bindPublishedScene('), editorSource.indexOf(' async previewAudio(')) const EditorMethods = new Function('saveTrainingProject', 'clone', 'publishTrainingProject', 'ENABLE_TRAINING_TOOLS', `return class { ${saveMethod} ${publishMethod} ${bindMethod} }`)( (_storage, project, options) => ({ ...project, status: options.status }), structuredClone, () => { throw new Error('Host-backed editor must not publish locally first') }, false, ) function editorFixture() { let resolve const pending = new Promise(done => { resolve = done }) const editor = new EditorMethods() Object.assign(editor, { can: () => true, readOnly: false, project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1, persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] }, onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) }, }) for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {} return { editor, resolve } } test('training without update permission cannot manually save or autosave its recovery buffer', async () => { const { editor } = editorFixture() editor.readOnly = true editor.can = () => false editor.onPersist = () => { throw new Error('no persistence permitted') } assert.equal(await editor.saveDraft({ notify: true }), null) assert.equal(await editor.saveDraft(), null) assert.equal(await editor.publishProject(), null) }) test('publisher-only training sends one publish intent without changing or saving local draft', async () => { const { editor } = editorFixture() editor.readOnly = true editor.can = permission => permission === 'content.training.publish' let calls = 0 editor.assetStore.validateReferences = () => { throw new Error('must use saved server version') } editor.onPersist = async payload => { calls += 1; assert.equal(payload.reason, 'publish'); return { status: 'published' } } assert.ok(await editor.publishProject()) assert.equal(calls, 1) assert.equal(editor.project.status, 'published') }) test('update-only training never invokes host persistence for a denied publish action', async () => { const { editor } = editorFixture() editor.can = permission => permission === 'content.training.update' editor.onPersist = () => { throw new Error('no publication permitted') } assert.equal(await editor.publishProject(), null) }) test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => { const f = editorFixture() const pending = f.editor.saveDraft({ notify: true }) assert.equal(f.editor.persisting, true) assert.equal(await f.editor.saveDraft({ notify: true }), null) f.resolve({ status: 'draft' }) assert.ok(await pending) assert.equal(f.editor.serverPending, false) assert.equal(f.editor.persisting, false) }) test('failed host save stays dirty and never reports a successful local result', async () => { const f = editorFixture() const pending = f.editor.saveDraft({ notify: true }) f.resolve(false) assert.equal(await pending, null) assert.equal(f.editor.serverPending, true) assert.equal(f.editor.dirty, true) }) test('new edits during a server save remain pending after the older snapshot succeeds', async () => { const f = editorFixture() const pending = f.editor.saveDraft({ notify: true }) f.editor.editRevision += 1 f.editor.dirty = true f.resolve({ status: 'draft' }) await pending assert.equal(f.editor.serverPending, true) assert.equal(f.editor.dirty, true) }) test('publishing keeps the editor in draft until the server confirms publication', async () => { const f = editorFixture() const pending = f.editor.publishProject() await Promise.resolve() assert.equal(f.editor.project.status, 'draft') assert.equal(f.editor.persisting, true) f.resolve({ status: 'published' }) assert.ok(await pending) assert.equal(f.editor.project.status, 'published') assert.equal(f.editor.serverPending, false) }) test('server-rejected publication leaves draft status and pending changes intact', async () => { const f = editorFixture() const pending = f.editor.publishProject() f.resolve(false) assert.equal(await pending, null) assert.equal(f.editor.project.status, 'draft') assert.equal(f.editor.serverPending, true) }) test('failed scene binding cannot persist a half-bound scene through save or publish', async () => { const { editor } = editorFixture() editor.project.scenario = { publishedSceneId: 'old@1', environment: {} } editor.dirty = false editor.serverPending = false editor.clearEnvironmentPreview = () => {} let writes = 0 editor.onPersist = async () => { writes += 1; return { status: 'draft' } } editor.loadServerScene = async () => ({ id: 'new@2', name: 'New scene', sourceProjectId: '2', sourceVersionId: '2', objects: [] }) let finishRender const loading = new Promise(resolve => { finishRender = resolve }) let renders = 0 editor.renderScene = () => ++renders === 1 ? loading : Promise.resolve({}) const binding = editor.bindPublishedScene('new@2') await Promise.resolve(); await Promise.resolve(); await Promise.resolve() assert.equal(editor.bindingScene, true) assert.equal(editor.project.scenario.publishedSceneId, 'new@2') assert.equal(await editor.saveDraft({ notify: true }), null) assert.equal(await editor.publishProject(), null) assert.equal(writes, 0) finishRender({ error: new Error('Model loader failed') }) await binding assert.equal(editor.bindingScene, false) assert.equal(editor.project.scenario.publishedSceneId, 'old@1') assert.equal(writes, 0) assert.equal(editor.dirty, false) assert.equal(editor.serverPending, false) }) test('scene binding cannot replace the document while an existing save is in flight', async () => { const { editor } = editorFixture() editor.persisting = true let loads = 0 editor.applyPublishedScene = async () => { loads += 1 } await editor.bindPublishedScene('new@2') assert.equal(loads, 0) })