|
- 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 source = readFileSync(new URL('../../unreal_tran_web/src/features/scene-legacy/contentApiClient.ts', import.meta.url), 'utf8')
- const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText
- const { LegacySceneContentApiClient, LegacySceneOperationQueue, saveSceneDocumentWithCover } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
- const clone = value => structuredClone(value)
- const jpeg = () => new Blob(['scene screenshot'], { type: 'image/jpeg' })
- const oldUri = `content://sha256/${'a'.repeat(64)}`
- const defer = () => {
- let resolve, reject
- const promise = new Promise((yes, no) => { resolve = yes; reject = no })
- return { promise, resolve, reject }
- }
-
- function fixture() {
- const state = {
- id: '987', type: 'SCENE', code: 'SCENE_TEST', name: 'Test scene', description: '', categoryCode: 'OUTDOOR',
- version: 7, status: 'DRAFT', currentVersionId: '107', publishedVersionId: '106', coverUri: oldUri,
- content: { objects: [] }, dependencies: [],
- assets: [{ id: '200', code: 'SCENE_COVER', name: 'Old cover', type: 'IMAGE', storageUri: oldUri,
- mimeType: 'image/jpeg', sizeBytes: 12, sha256: 'a'.repeat(64), metadata: {}, status: 'READY', sortOrder: 2 }],
- }
- const controls = { uploadStatus: 200, putStatus: 200, uploadBarrier: null, uploadPatch: null }
- const requests = []
- const published = clone(state)
- const client = new LegacySceneContentApiClient(state.id, {
- tokenStorage: { accessToken: () => '', refreshToken: () => '', store() {}, clear() {}, notifyExpired() {} },
- lockManager: null,
- fetchImpl: async (url, init) => {
- const reply = (status, data) => new Response(JSON.stringify({ code: status, message: status >= 400 ? 'test failure' : 'ok', data }), { status })
- if (init.method === 'POST') {
- assert.equal(url, '/api/tran/v1/content/projects/987/assets/upload')
- const form = init.body
- requests.push({ method: 'POST', version: Number(form.get('projectVersion')), code: form.get('code'),
- type: form.get('type'), file: form.get('file'), metadata: JSON.parse(form.get('metadata')) })
- if (controls.uploadBarrier) await controls.uploadBarrier.promise
- if (controls.uploadStatus !== 200) return reply(controls.uploadStatus, null)
- assert.equal(Number(form.get('projectVersion')), state.version)
- return reply(200, { code: form.get('code'), name: form.get('name'), type: form.get('type'),
- storageUri: `content://sha256/${'b'.repeat(64)}`, mimeType: form.get('file').type, sizeBytes: form.get('file').size,
- sha256: 'b'.repeat(64), metadata: JSON.parse(form.get('metadata')), status: 'READY',
- sortOrder: Number(form.get('sortOrder')), uploadTicket: 'scene-cover-ticket', ...controls.uploadPatch })
- }
- assert.equal(init.method, 'PUT')
- const body = JSON.parse(init.body)
- requests.push({ method: 'PUT', body })
- if (controls.putStatus !== 200) return reply(controls.putStatus, null)
- assert.equal(body.version, state.version)
- Object.assign(state, clone(body), { version: state.version + 1 })
- state.assets.forEach(asset => { delete asset.uploadTicket })
- return reply(200, state)
- },
- })
- return { client, state, controls, requests, published }
- }
-
- test('scene image ticket, fixed asset replacement, dependencies and coverUri commit in one versioned PUT', async () => {
- const f = fixture()
- const original = clone(f.state)
- const document = { objects: [{ targetProjectId: '156', targetVersionId: '184', position: [2, 0, 0] }] }
- const saved = await saveSceneDocumentWithCover(f.client, original, document, { image: jpeg() })
- assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
- assert.equal(f.requests[0].code, 'SCENE_COVER')
- assert.equal(f.requests[0].type, 'IMAGE')
- assert.equal(f.requests[0].file.type, 'image/jpeg')
- assert.equal(f.requests[0].file.name, 'scene-cover.jpg')
- assert.equal(f.requests[0].metadata.usage, 'scene-cover')
- const put = f.requests[1].body
- assert.equal(put.assets.length, 1)
- assert.equal(put.assets[0].uploadTicket, 'scene-cover-ticket')
- assert.equal(put.assets[0].sortOrder, 2)
- assert.equal(put.coverUri, put.assets[0].storageUri)
- assert.deepEqual(put.content, document)
- assert.deepEqual(put.dependencies, [{ targetProjectId: '156', targetVersionId: '184', relationType: 'SCENE_MODEL', required: true }])
- assert.equal(saved.project.version, 8)
- assert.equal(saved.coverFailed, false)
- assert.deepEqual(original, f.published, 'input and immutable published snapshot remain unchanged')
- })
-
- test('capture failure saves content with the previous cover and reports a warning result', async () => {
- const f = fixture()
- const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { error: new Error('tainted canvas') })
- assert.deepEqual(f.requests.map(r => r.method), ['PUT'])
- assert.equal(saved.coverFailed, true)
- assert.equal(saved.project.coverUri, oldUri)
- assert.equal(saved.project.content.label, 'edited')
- })
-
- test('upload failure keeps the previous cover but still saves scene content once', async () => {
- const f = fixture()
- f.controls.uploadStatus = 503
- const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'edited' }, { image: jpeg() })
- assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT'])
- assert.equal(saved.coverFailed, true)
- assert.equal(saved.project.coverUri, oldUri)
- assert.equal(f.requests[1].body.assets[0].uploadTicket, undefined)
- })
-
- test('upload conflict propagates without any content PUT', async () => {
- const f = fixture()
- f.controls.uploadStatus = 409
- await assert.rejects(saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }), error => error.status === 409)
- assert.deepEqual(f.requests.map(r => r.method), ['POST'])
- assert.equal(f.state.version, 7)
- })
-
- for (const status of [409, 503]) {
- test(`final PUT ${status} keeps old project and never leaks the ticket into a later plain save`, async () => {
- const f = fixture()
- const original = clone(f.state)
- f.controls.putStatus = status
- await assert.rejects(saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'failed' }, { image: jpeg() }), error => error.status === status)
- assert.deepEqual(original, f.published)
- assert.deepEqual(f.state, f.published)
- f.controls.putStatus = 200
- const saved = await saveSceneDocumentWithCover(f.client, original, { objects: [], label: 'plain' })
- assert.equal(saved.project.coverUri, oldUri)
- assert.equal(f.requests.at(-1).body.assets[0].uploadTicket, undefined)
- assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
- })
- }
-
- test('malformed image upload response is treated as optional cover failure', async () => {
- const f = fixture()
- f.controls.uploadPatch = { storageUri: 'blob:temporary' }
- const saved = await saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() })
- assert.equal(saved.coverFailed, true)
- assert.equal(saved.project.coverUri, oldUri)
- })
-
- test('switching project while upload is pending prevents the old document PUT', async () => {
- const f = fixture()
- f.controls.uploadBarrier = defer()
- let current = true
- const pending = saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [] }, { image: jpeg() }, () => {
- if (!current) throw new Error('project switched')
- })
- current = false
- f.controls.uploadBarrier.resolve()
- await assert.rejects(pending, /project switched/)
- assert.deepEqual(f.requests.map(r => r.method), ['POST'])
- assert.equal(f.state.version, 7)
- })
-
- test('the shared operation queue serializes cover upload and final PUT before the next write', async () => {
- const f = fixture()
- const queue = new LegacySceneOperationQueue()
- f.controls.uploadBarrier = defer()
- const first = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'cover' }, { image: jpeg() }))
- const second = queue.enqueue(() => saveSceneDocumentWithCover(f.client, clone(f.state), { objects: [], label: 'next' }))
- await new Promise(resolve => setImmediate(resolve))
- assert.deepEqual(f.requests.map(r => r.method), ['POST'])
- f.controls.uploadBarrier.resolve()
- await Promise.all([first, second])
- assert.deepEqual(f.requests.map(r => r.method), ['POST', 'PUT', 'PUT'])
- assert.deepEqual(f.requests.filter(r => r.body).map(r => r.body.version), [7, 8])
- })
-
- const editorSource = readFileSync(new URL('../../unreal_tran_web/src/features/guide-legacy/demo-src/scene-editor.js', import.meta.url), 'utf8').replace(/\r\n?/g, '\n')
- const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject() {'), editorSource.indexOf(' /**\n * 读取已存场景')).trim()
- const { saveProject } = new Function(`return ({ ${saveMethod} })`)()
- function editorFixture(saveDocument) {
- return {
- readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, dirty: false, changeRevision: 0,
- context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {},
- updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) },
- }
- }
-
- test('editor displays the partial-save warning instead of a success toast', async () => {
- const editor = editorFixture(async () => ({ warning: '场景已保存,封面更新失败,已保留原封面' }))
- await saveProject.call(editor)
- assert.equal(editor.dirty, false)
- assert.equal(editor.saving, false)
- assert.equal(editor.messages.at(-1).type, 'warn')
- assert.match(editor.messages.at(-1).message, /封面更新失败/)
- })
-
- test('editor failure remains dirty and duplicate clicks do not issue a second save', async () => {
- const barrier = defer()
- let calls = 0
- const editor = editorFixture(async () => { calls += 1; await barrier.promise })
- const pending = saveProject.call(editor)
- await saveProject.call(editor)
- assert.equal(calls, 1)
- barrier.reject(new Error('conflict'))
- await pending
- assert.equal(editor.dirty, true)
- assert.equal(editor.saving, false)
- assert.match(editor.messages.at(-1).message, /保存失败.*conflict/)
- })
-
- test('changes made while cover uploads remain dirty after the earlier snapshot saves', async () => {
- const barrier = defer()
- const editor = editorFixture(() => barrier.promise)
- const pending = saveProject.call(editor)
- editor.changeRevision += 1
- barrier.resolve({})
- await pending
- assert.equal(editor.dirty, true)
- assert.match(editor.messages.at(-1).message, /新修改仍待保存/)
- })
-
- const publishMethod = editorSource.slice(editorSource.indexOf(' async publishScene() {'), editorSource.indexOf(' /*\n * 原实现只设置原生 hidden')).trim()
- const { publishScene } = new Function(`return ({ ${publishMethod} })`)()
- function publishingEditor(saveDocument) {
- const editor = editorFixture(saveDocument)
- editor.canPublish = true
- editor.objects = []
- editor.assetStore = { validateReferences: async () => ({ valid: true }) }
- editor.verifyScene = () => [{ ok: true }]
- editor.saveProject = saveProject
- editor.publishCalls = 0
- editor.context.publishDocument = async () => { editor.publishCalls += 1 }
- return editor
- }
-
- const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8')
- const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave()'), hostSource.indexOf('function resetProjectSession()'))
- const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText
- function leaveEditor(editor) {
- return new Function('editorHandle', 'ElMessageBox', 'ElMessage', `${leaveCompiled}\nreturn confirmLeave()`)(
- editor,
- { confirm: async () => undefined },
- { error: message => editor.toast(message, 'error') },
- )
- }
-
- for (const status of [409, 503]) {
- test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => {
- const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) })
- await publishScene.call(editor)
- assert.equal(editor.publishCalls, 0)
- assert.equal(editor.dirty, true)
- assert.equal(editor.publishing, false)
- assert.equal(editor.messages.length, 1)
- assert.match(editor.messages[0].message, new RegExp(`HTTP ${status}`))
- })
- }
-
- test('save-and-leave keeps the editor open with one failure message', async () => {
- const editor = publishingEditor(async () => { throw new Error('version conflict') })
- editor.dirty = true
- assert.equal(await leaveEditor(editor), false)
- assert.equal(editor.dirty, true)
- assert.equal(editor.messages.length, 1)
- assert.match(editor.messages[0].message, /version conflict/)
- })
-
- for (const action of ['publish', 'leave']) {
- test(`${action} stays blocked when edits arrive during save, without claiming save failed`, async () => {
- const editor = publishingEditor(async () => { editor.changeRevision += 1; return {} })
- editor.dirty = true
- if (action === 'publish') await publishScene.call(editor)
- else assert.equal(await leaveEditor(editor), false)
- assert.equal(editor.publishCalls, 0)
- assert.equal(editor.dirty, true)
- assert.equal(editor.messages.length, 1)
- assert.match(editor.messages[0].message, /新修改仍待保存/)
- assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/)
- })
- }
|