You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

258 rivejä
16 KiB

  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import { readFile } from 'node:fs/promises'
  4. import { createRequire } from 'node:module'
  5. const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url))
  6. const requirePrototype = createRequire(new URL('../../unreal_tran/package.json', import.meta.url))
  7. const { Window } = requirePrototype('happy-dom')
  8. const ts = requireWeb('typescript')
  9. const web = new URL('../../unreal_tran_web/src/', import.meta.url)
  10. const dataModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
  11. async function compile(file, replacements = {}) {
  12. let source = await readFile(new URL(file, web), 'utf8')
  13. for (const [from, to] of Object.entries(replacements)) source = source.replaceAll(from, to)
  14. return dataModule(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText)
  15. }
  16. const { canUsePermission } = await import(await compile('config/roles.ts'))
  17. const { contentPermission, contentReviewPermission } = await import(await compile('utils/contentPermissions.ts'))
  18. test('content abilities use exact grants independently of teacher/admin/custom role names', () => {
  19. for (const code of ['admin', 'teacher', 'custom-author', 'student']) {
  20. assert.equal(canUsePermission('content.model.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'SINGLE_ACTIVE'), true)
  21. assert.equal(canUsePermission('content.scene.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
  22. assert.equal(canUsePermission('content.model.update', ['content.update'], [{ code, enabled: true, active: true }], 'UNION'), false)
  23. }
  24. })
  25. test('copy, delete, review, revoke and submit retain separate action grants', () => {
  26. assert.equal(contentPermission('GUIDE', 'create'), 'content.ofd.create')
  27. assert.equal(contentPermission('TRAINING', 'delete'), 'content.training.delete')
  28. for (const action of ['APPROVE', 'REJECT']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.review')
  29. assert.equal(contentReviewPermission('GUIDE', 'REVOKE'), 'content.ofd.publish')
  30. for (const action of ['SUBMIT', 'WITHDRAW', 'REVISE']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.update')
  31. })
  32. const authUrl = dataModule(`export const auth = { profile: { userId: 'reader', displayName: 'Actual user', permissions: [] }, activeRole: { code: 'admin' }, availableRoles: [], hasEffectiveRole: () => false, hasPermission(permission) { return this.profile.permissions.includes(permission) } }; export const useAuthStore = () => auth;`)
  33. const { auth } = await import(authUrl)
  34. const storageUrl = await compile('features/legacy-shared/scopedStorage.ts')
  35. const guideRuntime = await import(await compile('features/guide-legacy/runtime.ts', {
  36. './demo-src/asset-store.js': new URL('features/guide-legacy/demo-src/asset-store.js', web).href,
  37. './demo-src/content-list-store.js': new URL('features/guide-legacy/demo-src/content-list-store.js', web).href,
  38. '../legacy-shared/scopedStorage': storageUrl,
  39. '../../stores/auth': authUrl,
  40. }))
  41. test('OFD runtime uses the real session and rejects local create/update/delete without exact grants', async () => {
  42. const { createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
  43. const context = guideRuntime.legacyGuideRuntimeContext()
  44. assert.equal(context.actor.id, 'reader')
  45. assert.equal(context.readOnly, true)
  46. assert.equal(context.can('content.create'), false)
  47. assert.equal(context.storage.length, 0, 'read-only initialization must not seed projects')
  48. assert.throws(() => context.contentStore.createOFDProject({ title: 'Denied' }), /权限/)
  49. auth.profile.permissions = ['content.ofd.create']
  50. const initial = createInitialOFDState()
  51. initial.project.name = 'Full initial payload'
  52. const created = context.contentStore.createOFDProject({ title: 'Created only', state: initial })
  53. assert.ok(created.id)
  54. assert.equal(created.author, 'Actual user')
  55. assert.throws(() => context.contentStore.saveOFDProjectState(created.id, created.state), /权限/)
  56. assert.throws(() => context.contentStore.deleteOFDProject(created.id), /权限/)
  57. auth.profile.permissions = ['content.ofd.delete']
  58. assert.equal(context.contentStore.deleteOFDProject(created.id), true)
  59. })
  60. const { canPerformOFDAction, createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web))
  61. test('OFD lifecycle does not treat admin/teacher identity as a publish grant', () => {
  62. const state = createInitialOFDState()
  63. for (const role of ['admin', 'teacher']) assert.equal(canPerformOFDAction(state, 'publish', { role, permissions: [] }), false)
  64. assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.publish'] }), true)
  65. assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.update'] }), false)
  66. })
  67. const { installLegacyModelPermissions } = await import(await compile('features/editors/model/legacy-iframe-permissions.ts'))
  68. function modelFixture({ update = false, publish = false, create = false, createNew, document } = {}) {
  69. const calls = []
  70. const editor = {
  71. apiEditorHydrated: true, apiState: 'ready', apiSession: { project: { id: 'test', version: 7 }, lastSavedFingerprint: '' },
  72. saveProject: async () => { calls.push('save'); return {} },
  73. newProject: async () => { calls.push('local-new'); return true },
  74. publishToSceneLibrary: async () => { calls.push('save-and-publish'); return {} },
  75. saveRecoveryCopy: () => calls.push('recovery'),
  76. serializeProject: () => ({}), toast: message => calls.push(message),
  77. transform: { enabled: true, detach() {}, attach() { calls.push('transform') } },
  78. }
  79. const doc = document || { defaultView: { editorApp: editor }, body: {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, removeEventListener() {} }
  80. doc.defaultView.editorApp = editor
  81. const previousObserver = globalThis.MutationObserver
  82. globalThis.MutationObserver = class { observe() {} disconnect() {} }
  83. const bridge = installLegacyModelPermissions(doc, {
  84. canCreate: () => create, canUpdate: () => update, canPublish: () => publish,
  85. createNew: createNew || (async () => { calls.push('server-new'); return true }),
  86. publishSaved: async version => { calls.push(['publish-saved', version]); return { id: 'test', version: 8 } },
  87. })
  88. globalThis.MutationObserver = previousObserver
  89. return { editor, calls, bridge }
  90. }
  91. test('model iframe read-only bridge blocks autosave, recovery writes and transform attach', async () => {
  92. const { editor, calls, bridge } = modelFixture()
  93. assert.equal(await editor.saveProject(true, { automatic: true }), null)
  94. assert.equal(await editor.publishToSceneLibrary(), null)
  95. editor.saveRecoveryCopy()
  96. editor.transform.attach({})
  97. assert.deepEqual(calls, [])
  98. assert.equal(editor.transform.enabled, false)
  99. bridge.dispose()
  100. })
  101. test('model iframe publisher-only uses the loaded optimistic-lock version without saving', async () => {
  102. const { editor, calls, bridge } = modelFixture({ publish: true })
  103. const published = await editor.publishToSceneLibrary()
  104. assert.equal(published.version, 8)
  105. assert.deepEqual(calls[0], ['publish-saved', 7])
  106. assert.equal(calls.includes('save-and-publish'), false)
  107. bridge.dispose()
  108. })
  109. test('read-only model search, native clip selection and preview keyboard activation remain usable without enabling edits', t => {
  110. const window = new Window({ url: 'http://localhost/editor.html' })
  111. t.after(() => window.happyDOM.cancelAsync())
  112. const doc = window.document
  113. doc.body.innerHTML = '<div class="editor-shell"><input id="left-search"><select id="native-clip-select"><option>walk</option><option>run</option></select><button data-action="preview">Preview</button><input id="project-name"><input id="model-file-input" type="file"><div id="inspector"><input id="position-x"></div></div>'
  114. const { bridge } = modelFixture({ document: doc })
  115. t.after(() => bridge.dispose())
  116. const dispatch = (selector, type, key = '') => doc.querySelector(selector).dispatchEvent(type === 'keydown'
  117. ? new window.KeyboardEvent(type, { key, bubbles: true, cancelable: true })
  118. : new window.Event(type, { bubbles: true, cancelable: true }))
  119. for (const key of ['x', 'Backspace', 'Enter']) assert.equal(dispatch('#left-search', 'keydown', key), true, `search ${key}`)
  120. assert.equal(dispatch('#left-search', 'input'), true)
  121. assert.equal(dispatch('#native-clip-select', 'change'), true, 'a clip choice changes preview playback, not the saved project')
  122. assert.equal(dispatch('#left-search', 'drop'), false, 'read-only control exceptions must not enable resource drops')
  123. for (const key of ['Enter', ' ']) assert.equal(dispatch('[data-action="preview"]', 'keydown', key), true, `preview activation ${key}`)
  124. assert.equal(dispatch('#project-name', 'input'), false)
  125. assert.equal(dispatch('#position-x', 'change'), false)
  126. assert.equal(dispatch('#model-file-input', 'change'), false)
  127. assert.equal(dispatch('[data-action="preview"]', 'keydown', 'Delete'), false)
  128. })
  129. test('committed role switch prevents iframe unload recovery from writing discarded changes', async () => {
  130. const { editor, calls, bridge } = modelFixture({ update: true, publish: true })
  131. bridge.roleSwitchCommitted()
  132. editor.saveRecoveryCopy()
  133. assert.equal(await editor.saveProject(), null)
  134. assert.equal(await editor.publishToSceneLibrary(), null)
  135. assert.deepEqual(calls, [])
  136. bridge.dispose()
  137. })
  138. test('read-only model viewing skips automatic migration and recovery without modifying saved data', async () => {
  139. const { editor, bridge } = modelFixture()
  140. editor.apiDocumentNeedsMigrationSave = true
  141. assert.equal(editor.apiDocumentNeedsMigrationSave, false)
  142. assert.equal(await editor.saveProject(true), null)
  143. bridge.dispose()
  144. assert.equal(editor.apiDocumentNeedsMigrationSave, true, 'the host must restore the original runtime state on disposal')
  145. })
  146. const { ModelContentProjectSession } = await import(new URL('../../unreal_tran/src/model-content-api.js', import.meta.url))
  147. const { createBuiltInTestProject: createModelDocument, normalizeProjectDocument: normalizeModelDocument } = await import(new URL('../../unreal_tran/src/editor-project-io.js', import.meta.url))
  148. test('model host dirty check uses the complete saved iframe document, including depot environments', async () => {
  149. for (const environmentType of ['outdoor', 'workshop', 'depot']) {
  150. const document = normalizeModelDocument(createModelDocument())
  151. document.environment.environmentType = environmentType
  152. document.environment.objects = { 'environment-fixture': { position: [1, 2, 3], visible: false } }
  153. document.camera.position = [7, 8, 9]
  154. const session = new ModelContentProjectSession({ projectId: '7', client: {
  155. async request(_path, { json }) {
  156. return { ...session.project, ...structuredClone(json), id: '7', version: session.project.version + 1 }
  157. },
  158. } })
  159. session.project = { id: '7', type: 'MODEL', name: document.name, version: 1, coverUri: '', assets: [], dependencies: [] }
  160. await session.queueSave(document)
  161. const { editor, bridge } = modelFixture({ update: true })
  162. editor.apiSession = session
  163. editor.serializeProject = () => structuredClone(document)
  164. assert.equal(bridge.isDirty(), false, `${environmentType}: successful save must be clean`)
  165. document.model = { id: 'fixture-part', visible: false }
  166. assert.equal(bridge.isDirty(), true, `${environmentType}: real model edits must remain dirty`)
  167. await session.queueSave(document)
  168. assert.equal(bridge.isDirty(), false, `${environmentType}: saving the real edit clears dirty state`)
  169. document.environment.objects['environment-fixture'].position[0] = 10
  170. assert.equal(bridge.isDirty(), true, `${environmentType}: environment edits must remain dirty`)
  171. bridge.dispose()
  172. const readonly = modelFixture()
  173. readonly.editor.apiSession = session
  174. readonly.editor.serializeProject = () => structuredClone(document)
  175. assert.equal(readonly.bridge.isDirty(), false, 'read-only viewing must not prompt to save')
  176. readonly.bridge.dispose()
  177. }
  178. })
  179. test('model create-only permission uses host creation without resetting or saving the loaded project', async () => {
  180. const { editor, bridge, calls } = modelFixture({ create: true })
  181. assert.equal(await editor.newProject(), true)
  182. assert.deepEqual(calls, ['server-new'])
  183. assert.equal(await editor.saveProject(), null)
  184. bridge.dispose()
  185. assert.equal(await editor.newProject(), true)
  186. assert.deepEqual(calls, ['server-new', 'local-new'], 'disposing restores the original runtime method')
  187. })
  188. test('model new prevents concurrent creation and releases its guard after cancellation or failure', async () => {
  189. let settle, attempts = 0
  190. const { editor, bridge } = modelFixture({ create: true, createNew: () => {
  191. attempts += 1
  192. return new Promise((resolve, reject) => { settle = { resolve, reject } })
  193. } })
  194. const first = editor.newProject()
  195. assert.equal(await editor.newProject(), false)
  196. assert.equal(attempts, 1)
  197. settle.resolve(false)
  198. assert.equal(await first, false)
  199. const second = editor.newProject()
  200. settle.reject(new Error('fixture creation failed'))
  201. await assert.rejects(second, /fixture creation failed/)
  202. const third = editor.newProject()
  203. assert.equal(attempts, 3, 'cancellation and rejection must both permit a retry')
  204. settle.resolve(true)
  205. assert.equal(await third, true)
  206. bridge.roleSwitchCommitted()
  207. assert.equal(await editor.newProject(), false)
  208. assert.equal(attempts, 3, 'a committed role switch must prevent late creation')
  209. bridge.dispose()
  210. })
  211. const policy = await import(await compile('features/guide/publishDeliveriesPolicy.ts'))
  212. const publishSource = await readFile(new URL('features/guide/publishDeliveries.ts', web), 'utf8')
  213. const publishFunction = ts.transpileModule(publishSource.slice(publishSource.indexOf('export async function publishGuideWithDeliveries(')), {
  214. compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
  215. }).outputText.replace(/^export /gm, '')
  216. const publishGuide = new Function('normalizeGuideContent', 'resolveGuideDeliveryAssets', 'commitGuideDeliveries', 'reusableGuideDelivery', 'assertCompleteGuideDeliveries', 'GUIDE_DELIVERY_ASSET_CODES', 'artifactFile', 'text', `${publishFunction};return publishGuideWithDeliveries`)(
  217. value => value, async () => ({}), policy.commitGuideDeliveries, policy.reusableGuideDelivery,
  218. policy.assertCompleteGuideDeliveries, policy.GUIDE_DELIVERY_ASSET_CODES,
  219. () => { throw new Error('publisher must not upload') }, value => String(value ?? '').trim(),
  220. )
  221. function guidePublisherFixture(savedFingerprint) {
  222. const calls = []
  223. const formats = ['OFD', 'OFFLINE_HTML']
  224. const assets = formats.map(format => ({ code: policy.GUIDE_DELIVERY_ASSET_CODES[format], type: 'DOCUMENT', status: 'READY', sha256: 'a'.repeat(64),
  225. storageUri: `content://${format}`, metadata: { deliveryFormat: format, sourceFingerprint: savedFingerprint } }))
  226. const dependencies = {
  227. loadProject: async () => ({ id: 'g', version: 17, status: 'DRAFT', content: {}, assets }),
  228. readAsset: async () => new Blob(), uploadAsset: async () => { calls.push('upload'); throw new Error('forbidden') },
  229. buildArtifacts: async () => ({ sourceFingerprint: 'current-source', artifacts: formats.map(format => ({ format, code: policy.GUIDE_DELIVERY_ASSET_CODES[format] })) }),
  230. publishProject: async (_id, _type, input) => { calls.push(input); return { id: 'g', version: 18 } },
  231. }
  232. return { calls, dependencies }
  233. }
  234. test('OFD publisher reuses the exact saved source artifacts and optimistic-lock version without uploading', async () => {
  235. const { calls, dependencies } = guidePublisherFixture('current-source')
  236. const result = await publishGuide('g', { allowUpload: false }, dependencies)
  237. assert.equal(result.project.version, 18)
  238. assert.deepEqual(result.uploadedFormats, [])
  239. assert.deepEqual(result.reusedFormats, ['OFD', 'OFFLINE_HTML'])
  240. assert.equal(calls.length, 1)
  241. assert.equal(calls[0].version, 17)
  242. assert.equal(calls[0].deliveries.length, 2)
  243. })
  244. test('OFD publisher cannot regenerate/upload stale artifacts or publish them against changed content', async () => {
  245. const { calls, dependencies } = guidePublisherFixture('stale-source')
  246. await assert.rejects(publishGuide('g', { allowUpload: false }, dependencies), /交付物/)
  247. assert.deepEqual(calls, [])
  248. })