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, /新修改仍待保存/) })