import { test, expect, type Frame, type Page } from '@playwright/test' import { signIn, openEditor, readyFrame, saveEditor, selectMesh, shot, closeSession, evidence, fixture, writeFixture, call } from './model-editor-closure-helpers' test.afterEach(async ({ page }) => { await closeSession(page) }) async function copy(token: string, label: string) { const source = fixture().originals.fox.id const original = await call(`tran/v1/content/projects/${source}`, token) const result = await call(`tran/v1/content/projects/${source}/copy`, token, { name: `闭环验收-${label}-${Date.now()}`, version: original.data.project.version }) expect(result.status).toBe(200) const id = String(result.data.project.id) const data = fixture(); data.extraProjectIds.push(id); writeFixture(data) return id } async function pose(frame: Frame) { return frame.evaluate(() => { const o = (window as any).editorApp.modelPivot return { position: o.position.toArray(), rotation: o.rotation.toArray().slice(0, 3), scale: o.scale.toArray() } }) } async function dragGizmo(page: Page, frame: Frame, mode: string) { await frame.locator(`[data-transform="${mode}"]`).click() const box = await frame.locator('#render-host canvas').first().boundingBox() expect(box).toBeTruthy() const points = await frame.evaluate(mode => { const e = (window as any).editorApp const handles = e.transform._gizmo.gizmo[mode].children.filter((o: any) => o.visible && o.name === 'X') const points: { x: number; y: number }[] = [] for (const handle of handles) { const attr = handle.geometry?.attributes?.position if (!attr) continue handle.geometry.computeBoundingBox() const center = handle.geometry.boundingBox.getCenter(e.camera.position.clone()) const candidates = [center] if (mode === 'rotate') for (let i = 0; i < attr.count; i += Math.max(1, Math.floor(attr.count / 24))) candidates.push(e.camera.position.clone().fromBufferAttribute(attr, i)) for (const v of candidates) { v.applyMatrix4(handle.matrixWorld).project(e.camera) points.push({ x: (v.x + 1) / 2, y: (1 - v.y) / 2 }) } } return points }, mode) let hit: { x: number; y: number } | undefined for (const point of points) { if (point.x < .05 || point.x > .95 || point.y < .05 || point.y > .95) continue const p = { x: box!.x + point.x * box!.width, y: box!.y + point.y * box!.height } await page.mouse.move(p.x, p.y) if (await frame.evaluate(() => (window as any).editorApp.transform.axis === 'X')) { hit = p; break } } expect(hit, `Real ${mode} X handle can be hovered`).toBeTruthy() await page.mouse.down() expect(await frame.evaluate(() => (window as any).editorApp.transform.dragging)).toBe(true) await page.mouse.move(hit!.x + 46, hit!.y - 31, { steps: 12 }) await page.mouse.up() expect(await frame.evaluate(() => (window as any).editorApp.transform.dragging)).toBe(false) } test('真实三维移动、旋转、缩放手柄拖动及撤销重做保存重开', async ({ page }, info) => { const token = await signIn(page) const id = await copy(token, '三维手柄') let frame = await openEditor(page, id) await selectMesh(frame) await frame.locator('[data-selection-scope="whole"]').click() await frame.locator('[data-action="focus"]').first().click() let previous = await pose(frame) const changes: unknown[] = [] for (const [mode, key] of [['translate', 'position'], ['rotate', 'rotation'], ['scale', 'scale']] as const) { await dragGizmo(page, frame, mode) const next = await pose(frame) expect(next[key]).not.toEqual(previous[key]) changes.push({ mode, before: previous[key], after: next[key] }) previous = next } await frame.locator('[data-action="undo"]').first().click() await expect.poll(async () => (await pose(frame)).scale).not.toEqual(previous.scale) await frame.locator('[data-action="redo"]').first().click() await expect.poll(async () => (await pose(frame)).scale).toEqual(previous.scale) await saveEditor(frame) await shot(page, info, '51-transform-gizmos') await page.reload(); frame = await readyFrame(page) const reopened = await pose(frame) for (const key of ['position', 'rotation', 'scale'] as const) for (let i = 0; i < 3; i++) expect(reopened[key][i]).toBeCloseTo(previous[key][i], 5) evidence('51-gizmos', { status: 'PASS', kind: 'e2e+runtime-readback', testTitle: info.title, featureIds: ['view.move', 'view.rotate', 'view.scale'], projectId: id, changes, reopened }) }) test('未保存离开:关闭确认保留工作、保存失败留在原页、重试保存后离开', async ({ page }, info) => { const token = await signIn(page) const id = await copy(token, '离开保护') const frame = await openEditor(page, id) await selectMesh(frame) await frame.locator('#custom-resource-code').fill('UNSAVED-LEAVE-CLOSURE') await frame.locator('#custom-resource-code').dispatchEvent('change') const list = page.getByRole('link', { name: '模型制作', exact: true }) await list.click() const dialog = page.locator('.el-message-box').filter({ hasText: '当前模型有未保存修改' }) await expect(dialog).toBeVisible() await dialog.locator('.el-message-box__headerbtn').click() await expect(dialog).toBeHidden() await expect(page).toHaveURL(new RegExp(`/models/${id}/edit`)) await expect(frame.locator('#custom-resource-code')).toHaveValue('UNSAVED-LEAVE-CLOSURE') const endpoint = `**/api/tran/v1/content/projects/${id}` await page.route(endpoint, async route => route.request().method() === 'PUT' ? route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 503, message: '验收模拟保存失败' }) }) : route.continue()) await list.click() await dialog.getByRole('button', { name: '保存并离开', exact: true }).click() await expect.poll(() => frame.evaluate(() => (window as any).editorApp.apiState)).toBe('error') await expect(page).toHaveURL(new RegExp(`/models/${id}/edit`)) await expect(frame.locator('#custom-resource-code')).toHaveValue('UNSAVED-LEAVE-CLOSURE') await shot(page, info, '52-leave-save-failure') await page.unroute(endpoint) await list.click() await dialog.getByRole('button', { name: '保存并离开', exact: true }).click() await expect(page).toHaveURL(/\/content\/models$/) const stored = await call(`tran/v1/content/projects/${id}`, token) expect(stored.status, stored.message).toBe(200) expect(JSON.stringify(stored.data.currentVersion.content)).toContain('UNSAVED-LEAVE-CLOSURE') evidence('52-leave', { status: 'PASS', kind: 'e2e+api', testTitle: info.title, featureIds: ['project.leave'], projectId: id, closeKeptDraft: true, failureKeptDraft: true, retriedSavePersisted: true }) })