|
- 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, {
- 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('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)
- })
|