No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

223 líneas
14 KiB

  1. import { test, expect, type Frame } from '@playwright/test'
  2. import { signIn, openEditor, readyFrame, saveEditor, selectMesh, setRange, shot, closeSession, evidence, call, fixture, writeFixture } from './model-editor-closure-helpers'
  3. test.afterEach(async ({ page }) => { await closeSession(page) })
  4. async function change(frame: Frame, selector: string, value: string) {
  5. await frame.locator(selector).fill(value)
  6. await frame.locator(selector).dispatchEvent('change')
  7. }
  8. async function selectId(frame: Frame, id: string) {
  9. const uuid = await frame.evaluate(id => {
  10. let found: any
  11. const e = (window as any).editorApp
  12. e.modelPivot.traverse((o: any) => { if (o.userData.editorId === id) found = o })
  13. return found?.uuid
  14. }, id)
  15. expect(uuid).toBeTruthy()
  16. await frame.locator('[data-selection-scope="part"]').click()
  17. await frame.locator(`[data-object-id="${uuid}"]`).click()
  18. }
  19. test('对象名称、变换、PBR、属性与扩展标记保存重开;撤销重做', async ({ page }, info) => {
  20. const token = await signIn(page)
  21. let frame = await openEditor(page)
  22. const mesh = await selectMesh(frame)
  23. await frame.locator('[data-inspector-tab="properties"]').click()
  24. await change(frame, '#object-name-input', '闭环狐狸可编辑部件')
  25. await change(frame, '[data-vector="position"][data-axis="x"]', '1.25')
  26. await change(frame, '[data-vector="rotation"][data-axis="y"]', '15')
  27. await change(frame, '[data-vector="scale"][data-axis="z"]', '1.1')
  28. await setRange(frame, '#material-color', '#cc9955')
  29. await setRange(frame, '#material-metalness', '0.25')
  30. await setRange(frame, '#material-roughness', '0.7')
  31. await setRange(frame, '#material-opacity', '0.85')
  32. await change(frame, '#custom-resource-code', 'CLOSURE-FOX-001')
  33. await frame.locator('#custom-operating-status').selectOption('NORMAL')
  34. await frame.locator('#custom-operating-status').selectOption('SERVICE')
  35. await frame.locator('[data-action="undo"]').first().click()
  36. await expect(frame.locator('#custom-operating-status')).toHaveValue('NORMAL')
  37. await frame.locator('[data-action="redo"]').first().click()
  38. await expect(frame.locator('#custom-operating-status')).toHaveValue('SERVICE')
  39. await frame.locator('[data-inspector-tab="components"]').click()
  40. const count = await frame.locator('.component-card').count()
  41. await frame.locator('.component-add').click()
  42. await expect(frame.locator('.component-card')).toHaveCount(count + 1)
  43. await expect(frame.locator('.component-card').last()).toContainText('仅保存标签')
  44. await saveEditor(frame)
  45. await shot(page, info, '01-components-saved')
  46. await page.reload()
  47. frame = await readyFrame(page)
  48. await selectId(frame, mesh.id)
  49. await expect(frame.locator('#object-name-input')).toHaveValue('闭环狐狸可编辑部件')
  50. await expect(frame.locator('#custom-resource-code')).toHaveValue('CLOSURE-FOX-001')
  51. await expect(frame.locator('#custom-operating-status')).toHaveValue('SERVICE')
  52. await expect(frame.locator('#material-color')).toHaveValue('#cc9955')
  53. await expect(frame.locator('#material-metalness')).toHaveValue('0.25')
  54. await expect(frame.locator('#material-roughness')).toHaveValue('0.7')
  55. await expect(frame.locator('#material-opacity')).toHaveValue('0.85')
  56. const state = await frame.evaluate(() => {
  57. const o = (window as any).editorApp.selected
  58. return { id: o.userData.editorId, position: o.position.toArray(), rotation: o.rotation.toArray().slice(0, 3), scale: o.scale.toArray(), customProperties: o.userData.customProperties, customComponents: o.userData.customComponents }
  59. })
  60. expect(state.position[0]).toBeCloseTo(1.25)
  61. expect(state.rotation[1]).toBeCloseTo(Math.PI / 12)
  62. expect(state.scale[2]).toBeCloseTo(1.1)
  63. expect(state.customComponents.length).toBeGreaterThan(0)
  64. const saved = await call(`tran/v1/content/projects/${fixture().models.fox.id}`, token)
  65. expect(saved.status).toBe(200)
  66. expect(JSON.stringify(saved.data.currentVersion.content)).toContain('CLOSURE-FOX-001')
  67. await frame.locator('[data-action="focus"]').first().click()
  68. await shot(page, info, '02-properties-reopened')
  69. evidence('properties', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
  70. featureIds: ['property.name', 'property.position', 'property.rotation', 'property.scale', 'property.color', 'property.metalness', 'property.roughness', 'property.opacity', 'property.code', 'property.status', 'component.tag', 'project.undo', 'project.redo', 'project.save'],
  71. scope: '真实属性输入/材质参数/标签、保存与刷新;变换只验数值输入,不代替三维 gizmo 拖动。', state, serverVersion: saved.data.project.version, persisted: true })
  72. })
  73. test('场景树搜索、可见性、选择、视口工具、原生动画与截图', async ({ page }, info) => {
  74. await signIn(page)
  75. let frame = await openEditor(page)
  76. const mesh = await selectMesh(frame)
  77. if (!await frame.evaluate(() => (window as any).editorApp.selected.visible)) await frame.locator(`[data-object-id="${mesh.uuid}"] .tree-eye`).click()
  78. await frame.locator('#left-search').fill('闭环狐狸可编辑部件')
  79. await expect(frame.locator(`[data-object-id="${mesh.uuid}"]`)).toBeVisible()
  80. await frame.locator('#left-search').fill('不存在的节点验收字符串')
  81. await expect(frame.locator('.tree-row')).toHaveCount(0)
  82. await frame.locator('#left-search').fill('')
  83. await frame.locator(`[data-object-id="${mesh.uuid}"] .tree-eye`).click()
  84. expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(false)
  85. await saveEditor(frame)
  86. await page.reload(); frame = await readyFrame(page)
  87. await selectId(frame, mesh.id)
  88. expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(false)
  89. await expect(frame.locator('.tag-row')).toContainText('已隐藏')
  90. const uuid = await frame.evaluate(() => (window as any).editorApp.selected.uuid)
  91. await frame.locator(`[data-object-id="${uuid}"] .tree-eye`).click()
  92. await frame.locator('[data-view="outline"]').click()
  93. await frame.locator('[data-selection-scope="whole"]').click()
  94. expect(await frame.evaluate(() => (window as any).editorApp.selectionHelper.visible)).toBe(false)
  95. await frame.locator('[data-selection-scope="part"]').click()
  96. await frame.locator('[data-view="outline"]').click()
  97. for (const mode of ['translate', 'rotate', 'scale']) {
  98. await frame.locator(`[data-transform="${mode}"]`).click()
  99. expect(await frame.evaluate(() => (window as any).editorApp.transform.mode)).toBe(mode)
  100. }
  101. await frame.locator('[data-view="wireframe"]').click()
  102. expect(await frame.evaluate(() => {
  103. let wire = false; (window as any).editorApp.modelPivot.traverse((o: any) => { if (o.isMesh) wire = (Array.isArray(o.material) ? o.material[0] : o.material).wireframe }); return wire
  104. })).toBe(true)
  105. await frame.locator('[data-view="wireframe"]').click()
  106. await frame.locator('[data-view="lit"]').click()
  107. expect(await frame.evaluate(() => (window as any).editorApp.scene.environment === null)).toBe(true)
  108. await frame.locator('[data-view="lit"]').click()
  109. await frame.locator('[data-action="grid"]').first().click()
  110. expect(await frame.evaluate(() => (window as any).editorApp.grid.visible)).toBe(false)
  111. await frame.locator('[data-action="grid"]').first().click()
  112. await frame.locator('[data-view="perspective"]').click()
  113. expect(await frame.evaluate(() => (window as any).editorApp.camera.fov)).toBe(42)
  114. const clips = await frame.locator('#native-clip-select option').count()
  115. expect(clips).toBeGreaterThan(1)
  116. await frame.locator('#native-clip-select').selectOption('1')
  117. await frame.locator('[data-action="toggle-native-clip"]').click()
  118. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAction.time)).toBeGreaterThan(0)
  119. await frame.locator('[data-action="toggle-native-clip"]').click()
  120. expect(await frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(false)
  121. await frame.locator('[data-action="preview"]').first().click()
  122. await expect(frame.locator('.editor-shell')).toHaveClass(/preview-mode/)
  123. expect(await frame.evaluate(() => (window as any).editorApp.transform.enabled)).toBe(false)
  124. await expect(frame.locator('#toast-stack .toast')).toHaveCount(0)
  125. await shot(page, info, '03-fox-preview')
  126. await frame.getByRole('button', { name: '退出预览 Esc', exact: true }).click()
  127. await saveEditor(frame)
  128. const downloadPromise = page.waitForEvent('download')
  129. await frame.locator('[data-action="snapshot"]').first().click()
  130. const download = await downloadPromise
  131. expect(download.suggestedFilename()).toMatch(/\.png$/i)
  132. expect(await download.failure()).toBeNull()
  133. await page.reload(); frame = await readyFrame(page)
  134. await expect(frame.locator('#native-clip-select')).toHaveValue('1')
  135. evidence('viewport', { status: 'PASS', kind: 'e2e', testTitle: info.title,
  136. featureIds: ['tree.search', 'tree.visibility', 'selection.sync', 'view.grid', 'view.pbr', 'view.wireframe', 'view.outline', 'view.perspective', 'view.preview', 'view.snapshot', 'timeline.native'],
  137. scope: '树搜索、对象隐藏刷新、轮廓及视图开关、原生动画和PNG下载。移动/旋转/缩放只切换模式,真实gizmo拖动仍需补验;聚焦未做几何断言。', clips, nativeClipPersisted: true, hiddenObjectPersisted: true, snapshot: download.suggestedFilename() })
  138. })
  139. test('新建工程取消及失败保留输入;成功创建新ID且不覆盖当前模型', async ({ page }, info) => {
  140. const token = await signIn(page)
  141. let frame = await openEditor(page)
  142. const id = fixture().models.fox.id
  143. const before = await call(`tran/v1/content/projects/${id}`, token)
  144. await frame.locator('[data-action="new"]').click()
  145. const dialog = page.getByRole('dialog', { name: '新建装备模型' })
  146. await expect(dialog).toBeVisible()
  147. await dialog.getByRole('button', { name: '取消', exact: true }).click()
  148. await expect(dialog).toBeHidden()
  149. expect(page.url()).toContain(`/models/${id}/edit`)
  150. await frame.locator('[data-action="new"]').click()
  151. const name = `闭环新建-${Date.now()}`
  152. await dialog.getByRole('textbox', { name: '新模型名称' }).fill(name)
  153. const endpoint = '**/api/tran/v1/content/projects'
  154. await page.route(endpoint, async route => route.request().method() === 'POST'
  155. ? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 503, message: '验收模拟新建暂时失败' }) }) : route.continue())
  156. await dialog.getByRole('button', { name: '创建', exact: true }).click()
  157. await expect(page.locator('.el-message')).toContainText('验收模拟新建暂时失败')
  158. await expect(dialog.getByRole('textbox', { name: '新模型名称' })).toHaveValue(name)
  159. await shot(page, info, '04-new-retry-dialog')
  160. await page.unroute(endpoint)
  161. const created = page.waitForResponse(response => response.url().endsWith('/api/tran/v1/content/projects') && response.request().method() === 'POST' && response.ok())
  162. await dialog.getByRole('button', { name: '创建', exact: true }).click()
  163. const response = await (await created).json()
  164. const newId = response.data.project?.id || response.data.id
  165. expect(newId).toBeTruthy()
  166. const data = fixture(); data.extraProjectIds.push(String(newId)); writeFixture(data)
  167. await expect(page).toHaveURL(new RegExp(`/models/${newId}/edit`))
  168. frame = await readyFrame(page)
  169. await expect(frame.locator('#project-name')).toHaveValue(name)
  170. const after = await call(`tran/v1/content/projects/${id}`, token)
  171. expect(after.data.project.version).toBe(before.data.project.version)
  172. expect(after.data.currentVersion.content).toEqual(before.data.currentVersion.content)
  173. await expect(page.locator('.el-message').filter({ hasText: '验收模拟新建暂时失败' })).toHaveCount(0)
  174. await shot(page, info, '05-new-separate-project')
  175. evidence('new-project', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.new'], oldId: id, newId, cancelPreserved: true, failureRetainedName: true, originalVersion: after.data.project.version, sourceUnchanged: true })
  176. })
  177. test('管理员只读权限:编辑、新建、导入、保存发布均不可越权', async ({ page }, info) => {
  178. const token = await signIn(page, 'admin')
  179. const frame = await openEditor(page)
  180. await expect(frame.locator('.editor-shell')).toHaveAttribute('data-host-readonly', 'true')
  181. await selectMesh(frame)
  182. await expect(frame.locator('[data-model-editability]')).toHaveText('只读')
  183. for (const action of ['new', 'import-model', 'open', 'save', 'undo', 'redo', 'publish']) {
  184. const button = frame.locator(`[data-action="${action}"]`).first()
  185. await expect(button).toBeDisabled()
  186. }
  187. const unexpectedWrites: string[] = []
  188. page.on('request', request => {
  189. if (/\/api\/tran\/v1\/content\//.test(request.url()) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
  190. unexpectedWrites.push(request.method())
  191. }
  192. })
  193. const search = frame.locator('#left-search')
  194. await search.pressSequentially('fox')
  195. await expect(search).toHaveValue('fox')
  196. await expect(frame.locator('#scene-tree .tree-row')).not.toHaveCount(0)
  197. await search.press('Control+A')
  198. await search.press('Backspace')
  199. await expect(search).toHaveValue('')
  200. const clips = frame.locator('#native-clip-select')
  201. const options = await clips.locator('option').count()
  202. expect(options).toBeGreaterThan(1)
  203. const nextClip = String((Number(await clips.inputValue()) + 1) % options)
  204. await clips.selectOption(nextClip)
  205. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeClipIndex)).toBe(Number(nextClip))
  206. const nativeToggle = frame.locator('#native-clip-toggle')
  207. await nativeToggle.focus()
  208. await nativeToggle.press('Enter')
  209. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(true)
  210. await nativeToggle.press('Enter')
  211. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(false)
  212. expect(unexpectedWrites).toEqual([])
  213. const denial = await call('tran/v1/content/projects', token, { type: 'MODEL', name: '不得创建', version: 0, content: {} })
  214. expect(denial.status).toBe(403)
  215. await shot(page, info, '06-readonly-permissions')
  216. evidence('permissions', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.permissions'], scope: '管理员只读UI、真实键盘搜索、原生片段切换与Enter播放/暂停、预览无内容写请求及创建403;纯publisher无需update的发布流程由独立权限合同回归补充。', readonlyUi: true, readonlySearch: true, nativeClipChanged: true, keyboardPreview: true, previewWriteRequests: unexpectedWrites.length, createStatus: denial.status })
  217. })