|
- import { test, expect, type Frame } from '@playwright/test'
- import { signIn, openEditor, readyFrame, saveEditor, selectMesh, setRange, shot, closeSession, evidence, call, fixture, writeFixture } from './model-editor-closure-helpers'
-
- test.afterEach(async ({ page }) => { await closeSession(page) })
- async function change(frame: Frame, selector: string, value: string) {
- await frame.locator(selector).fill(value)
- await frame.locator(selector).dispatchEvent('change')
- }
- async function selectId(frame: Frame, id: string) {
- const uuid = await frame.evaluate(id => {
- let found: any
- const e = (window as any).editorApp
- e.modelPivot.traverse((o: any) => { if (o.userData.editorId === id) found = o })
- return found?.uuid
- }, id)
- expect(uuid).toBeTruthy()
- await frame.locator('[data-selection-scope="part"]').click()
- await frame.locator(`[data-object-id="${uuid}"]`).click()
- }
-
- test('对象名称、变换、PBR、属性与扩展标记保存重开;撤销重做', async ({ page }, info) => {
- const token = await signIn(page)
- let frame = await openEditor(page)
- const mesh = await selectMesh(frame)
- await frame.locator('[data-inspector-tab="properties"]').click()
- await change(frame, '#object-name-input', '闭环狐狸可编辑部件')
- await change(frame, '[data-vector="position"][data-axis="x"]', '1.25')
- await change(frame, '[data-vector="rotation"][data-axis="y"]', '15')
- await change(frame, '[data-vector="scale"][data-axis="z"]', '1.1')
- await setRange(frame, '#material-color', '#cc9955')
- await setRange(frame, '#material-metalness', '0.25')
- await setRange(frame, '#material-roughness', '0.7')
- await setRange(frame, '#material-opacity', '0.85')
- await change(frame, '#custom-resource-code', 'CLOSURE-FOX-001')
- await frame.locator('#custom-operating-status').selectOption('NORMAL')
- await frame.locator('#custom-operating-status').selectOption('SERVICE')
- await frame.locator('[data-action="undo"]').first().click()
- await expect(frame.locator('#custom-operating-status')).toHaveValue('NORMAL')
- await frame.locator('[data-action="redo"]').first().click()
- await expect(frame.locator('#custom-operating-status')).toHaveValue('SERVICE')
- await frame.locator('[data-inspector-tab="components"]').click()
- const count = await frame.locator('.component-card').count()
- await frame.locator('.component-add').click()
- await expect(frame.locator('.component-card')).toHaveCount(count + 1)
- await expect(frame.locator('.component-card').last()).toContainText('仅保存标签')
- await saveEditor(frame)
- await shot(page, info, '01-components-saved')
- await page.reload()
- frame = await readyFrame(page)
- await selectId(frame, mesh.id)
- await expect(frame.locator('#object-name-input')).toHaveValue('闭环狐狸可编辑部件')
- await expect(frame.locator('#custom-resource-code')).toHaveValue('CLOSURE-FOX-001')
- await expect(frame.locator('#custom-operating-status')).toHaveValue('SERVICE')
- await expect(frame.locator('#material-color')).toHaveValue('#cc9955')
- await expect(frame.locator('#material-metalness')).toHaveValue('0.25')
- await expect(frame.locator('#material-roughness')).toHaveValue('0.7')
- await expect(frame.locator('#material-opacity')).toHaveValue('0.85')
- const state = await frame.evaluate(() => {
- const o = (window as any).editorApp.selected
- 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 }
- })
- expect(state.position[0]).toBeCloseTo(1.25)
- expect(state.rotation[1]).toBeCloseTo(Math.PI / 12)
- expect(state.scale[2]).toBeCloseTo(1.1)
- expect(state.customComponents.length).toBeGreaterThan(0)
- const saved = await call(`tran/v1/content/projects/${fixture().models.fox.id}`, token)
- expect(saved.status).toBe(200)
- expect(JSON.stringify(saved.data.currentVersion.content)).toContain('CLOSURE-FOX-001')
- await frame.locator('[data-action="focus"]').first().click()
- await shot(page, info, '02-properties-reopened')
- evidence('properties', { status: 'PASS', kind: 'e2e+api', testTitle: info.title,
- 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'],
- scope: '真实属性输入/材质参数/标签、保存与刷新;变换只验数值输入,不代替三维 gizmo 拖动。', state, serverVersion: saved.data.project.version, persisted: true })
- })
-
- test('场景树搜索、可见性、选择、视口工具、原生动画与截图', async ({ page }, info) => {
- await signIn(page)
- let frame = await openEditor(page)
- const mesh = await selectMesh(frame)
- if (!await frame.evaluate(() => (window as any).editorApp.selected.visible)) await frame.locator(`[data-object-id="${mesh.uuid}"] .tree-eye`).click()
- await frame.locator('#left-search').fill('闭环狐狸可编辑部件')
- await expect(frame.locator(`[data-object-id="${mesh.uuid}"]`)).toBeVisible()
- await frame.locator('#left-search').fill('不存在的节点验收字符串')
- await expect(frame.locator('.tree-row')).toHaveCount(0)
- await frame.locator('#left-search').fill('')
- await frame.locator(`[data-object-id="${mesh.uuid}"] .tree-eye`).click()
- expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(false)
- await saveEditor(frame)
- await page.reload(); frame = await readyFrame(page)
- await selectId(frame, mesh.id)
- expect(await frame.evaluate(() => (window as any).editorApp.selected.visible)).toBe(false)
- await expect(frame.locator('.tag-row')).toContainText('已隐藏')
- const uuid = await frame.evaluate(() => (window as any).editorApp.selected.uuid)
- await frame.locator(`[data-object-id="${uuid}"] .tree-eye`).click()
- await frame.locator('[data-view="outline"]').click()
- await frame.locator('[data-selection-scope="whole"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.selectionHelper.visible)).toBe(false)
- await frame.locator('[data-selection-scope="part"]').click()
- await frame.locator('[data-view="outline"]').click()
- for (const mode of ['translate', 'rotate', 'scale']) {
- await frame.locator(`[data-transform="${mode}"]`).click()
- expect(await frame.evaluate(() => (window as any).editorApp.transform.mode)).toBe(mode)
- }
- await frame.locator('[data-view="wireframe"]').click()
- expect(await frame.evaluate(() => {
- 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
- })).toBe(true)
- await frame.locator('[data-view="wireframe"]').click()
- await frame.locator('[data-view="lit"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.scene.environment === null)).toBe(true)
- await frame.locator('[data-view="lit"]').click()
- await frame.locator('[data-action="grid"]').first().click()
- expect(await frame.evaluate(() => (window as any).editorApp.grid.visible)).toBe(false)
- await frame.locator('[data-action="grid"]').first().click()
- await frame.locator('[data-view="perspective"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.camera.fov)).toBe(42)
- const clips = await frame.locator('#native-clip-select option').count()
- expect(clips).toBeGreaterThan(1)
- await frame.locator('#native-clip-select').selectOption('1')
- await frame.locator('[data-action="toggle-native-clip"]').click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAction.time)).toBeGreaterThan(0)
- await frame.locator('[data-action="toggle-native-clip"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(false)
- await frame.locator('[data-action="preview"]').first().click()
- await expect(frame.locator('.editor-shell')).toHaveClass(/preview-mode/)
- expect(await frame.evaluate(() => (window as any).editorApp.transform.enabled)).toBe(false)
- await expect(frame.locator('#toast-stack .toast')).toHaveCount(0)
- await shot(page, info, '03-fox-preview')
- await frame.getByRole('button', { name: '退出预览 Esc', exact: true }).click()
- await saveEditor(frame)
- const downloadPromise = page.waitForEvent('download')
- await frame.locator('[data-action="snapshot"]').first().click()
- const download = await downloadPromise
- expect(download.suggestedFilename()).toMatch(/\.png$/i)
- expect(await download.failure()).toBeNull()
- await page.reload(); frame = await readyFrame(page)
- await expect(frame.locator('#native-clip-select')).toHaveValue('1')
- evidence('viewport', { status: 'PASS', kind: 'e2e', testTitle: info.title,
- featureIds: ['tree.search', 'tree.visibility', 'selection.sync', 'view.grid', 'view.pbr', 'view.wireframe', 'view.outline', 'view.perspective', 'view.preview', 'view.snapshot', 'timeline.native'],
- scope: '树搜索、对象隐藏刷新、轮廓及视图开关、原生动画和PNG下载。移动/旋转/缩放只切换模式,真实gizmo拖动仍需补验;聚焦未做几何断言。', clips, nativeClipPersisted: true, hiddenObjectPersisted: true, snapshot: download.suggestedFilename() })
- })
-
- test('新建工程取消及失败保留输入;成功创建新ID且不覆盖当前模型', async ({ page }, info) => {
- const token = await signIn(page)
- let frame = await openEditor(page)
- const id = fixture().models.fox.id
- const before = await call(`tran/v1/content/projects/${id}`, token)
- await frame.locator('[data-action="new"]').click()
- const dialog = page.getByRole('dialog', { name: '新建装备模型' })
- await expect(dialog).toBeVisible()
- await dialog.getByRole('button', { name: '取消', exact: true }).click()
- await expect(dialog).toBeHidden()
- expect(page.url()).toContain(`/models/${id}/edit`)
- await frame.locator('[data-action="new"]').click()
- const name = `闭环新建-${Date.now()}`
- await dialog.getByRole('textbox', { name: '新模型名称' }).fill(name)
- const endpoint = '**/api/tran/v1/content/projects'
- await page.route(endpoint, async route => route.request().method() === 'POST'
- ? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 503, message: '验收模拟新建暂时失败' }) }) : route.continue())
- await dialog.getByRole('button', { name: '创建', exact: true }).click()
- await expect(page.locator('.el-message')).toContainText('验收模拟新建暂时失败')
- await expect(dialog.getByRole('textbox', { name: '新模型名称' })).toHaveValue(name)
- await shot(page, info, '04-new-retry-dialog')
- await page.unroute(endpoint)
- const created = page.waitForResponse(response => response.url().endsWith('/api/tran/v1/content/projects') && response.request().method() === 'POST' && response.ok())
- await dialog.getByRole('button', { name: '创建', exact: true }).click()
- const response = await (await created).json()
- const newId = response.data.project?.id || response.data.id
- expect(newId).toBeTruthy()
- const data = fixture(); data.extraProjectIds.push(String(newId)); writeFixture(data)
- await expect(page).toHaveURL(new RegExp(`/models/${newId}/edit`))
- frame = await readyFrame(page)
- await expect(frame.locator('#project-name')).toHaveValue(name)
- const after = await call(`tran/v1/content/projects/${id}`, token)
- expect(after.data.project.version).toBe(before.data.project.version)
- expect(after.data.currentVersion.content).toEqual(before.data.currentVersion.content)
- await expect(page.locator('.el-message').filter({ hasText: '验收模拟新建暂时失败' })).toHaveCount(0)
- await shot(page, info, '05-new-separate-project')
- 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 })
- })
-
- test('管理员只读权限:编辑、新建、导入、保存发布均不可越权', async ({ page }, info) => {
- const token = await signIn(page, 'admin')
- const frame = await openEditor(page)
- await expect(frame.locator('.editor-shell')).toHaveAttribute('data-host-readonly', 'true')
- await selectMesh(frame)
- await expect(frame.locator('[data-model-editability]')).toHaveText('只读')
- for (const action of ['new', 'import-model', 'open', 'save', 'undo', 'redo', 'publish']) {
- const button = frame.locator(`[data-action="${action}"]`).first()
- await expect(button).toBeDisabled()
- }
- const unexpectedWrites: string[] = []
- page.on('request', request => {
- if (/\/api\/tran\/v1\/content\//.test(request.url()) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
- unexpectedWrites.push(request.method())
- }
- })
- const search = frame.locator('#left-search')
- await search.pressSequentially('fox')
- await expect(search).toHaveValue('fox')
- await expect(frame.locator('#scene-tree .tree-row')).not.toHaveCount(0)
- await search.press('Control+A')
- await search.press('Backspace')
- await expect(search).toHaveValue('')
- const clips = frame.locator('#native-clip-select')
- const options = await clips.locator('option').count()
- expect(options).toBeGreaterThan(1)
- const nextClip = String((Number(await clips.inputValue()) + 1) % options)
- await clips.selectOption(nextClip)
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeClipIndex)).toBe(Number(nextClip))
- const nativeToggle = frame.locator('#native-clip-toggle')
- await nativeToggle.focus()
- await nativeToggle.press('Enter')
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(true)
- await nativeToggle.press('Enter')
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.nativeAnimationPlaying)).toBe(false)
- expect(unexpectedWrites).toEqual([])
- const denial = await call('tran/v1/content/projects', token, { type: 'MODEL', name: '不得创建', version: 0, content: {} })
- expect(denial.status).toBe(403)
- await shot(page, info, '06-readonly-permissions')
- 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 })
- })
|