|
- import { test, expect, type Frame, type Locator, type Page } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
- import crypto from 'node:crypto'
- import { signIn, openEditor, readyFrame, selectMesh, setRange, saveEditor, shot, closeSession, fixture, evidence, call, work, writeFixture } from './model-editor-closure-helpers'
-
- // Real backend + real editor. This file owns only the copied truck fixture.
- // evaluate() reads editor state, except explicitly labelled runtime branch checks.
- // Auth state stays in the helper/browser; no trace or storageState is exported.
- test.describe.configure({ mode: 'serial' })
-
- type NodeIds = Record<'trigger' | 'play' | 'wait' | 'highlight' | 'message' | 'condition' | 'finish', string>
- let authoredGraph: NodeIds
- let targetAlias: string
- let authoredTrack: { id: string; middleId: string; time: number; targetId: string }
-
- const timeline = (frame: Frame) => frame.locator('#timeline-root')
- const blueprint = (frame: Frame) => frame.locator('#blueprint-root')
- const node = (frame: Frame, id: string) => blueprint(frame).locator(`.bp-node[data-node-id="${id}"]`)
-
- async function fillChanged(input: Locator, value: string) {
- await input.fill(value)
- await input.press('Tab')
- }
-
- async function activate(frame: Frame, name: string) {
- await frame.locator(`[data-bottom-tab="${name}"]`).click()
- await expect(frame.locator(`.editor-mode[data-mode="${name}"]`)).toHaveClass(/active/)
- }
-
- async function trackState(frame: Frame, id: string) {
- return frame.evaluate(id => (window as any).editorApp.timeline.getTrack(id), id)
- }
-
- async function saveTruck(page: Page, frame: Frame, id = fixture().models.truck.id) {
- expect([94, 156, 157]).not.toContain(Number(id))
- const response = page.waitForResponse(response => response.request().method() === 'PUT' &&
- new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 90_000 })
- await saveEditor(frame)
- expect((await response).status()).toBe(200)
- await expect.poll(() => frame.evaluate(() => {
- const e = (window as any).editorApp
- return e.apiState === 'ready' && !e.apiSession.savePending && !e.apiCoverSavePending
- }), { timeout: 90_000 }).toBe(true)
- }
-
- async function bpField(frame: Frame, label: RegExp) {
- return blueprint(frame).locator('.bp-field').filter({
- has: frame.locator('.bp-field-label').filter({ hasText: label }),
- }).locator('input,select,textarea')
- }
-
- async function fieldText(frame: Frame, label: RegExp, value: string) {
- await fillChanged(await bpField(frame, label), value)
- }
-
- async function selectNode(frame: Frame, id: string) {
- await node(frame, id).locator('.bp-node-header').click()
- await expect(node(frame, id)).toHaveClass(/bp-selected/)
- }
-
- async function addNode(page: Page, frame: Frame, title: string, type: string, column: number, row: number) {
- await blueprint(frame).locator('.bp-palette-item').filter({ hasText: title }).click()
- const id = await frame.evaluate(() => [...(window as any).editorApp.blueprint.selection][0] as string)
- expect(await frame.evaluate(id => (window as any).editorApp.blueprint.nodes.find((n: any) => n.id === id).type, id)).toBe(type)
- const header = node(frame, id).locator('.bp-node-header')
- const box = await header.boundingBox()
- const viewport = await blueprint(frame).locator('.bp-viewport').boundingBox()
- expect(box).not.toBeNull(); expect(viewport).not.toBeNull()
- // Actual pointer drag. Arrange headers in a 3 x 3 grid, then use the editor's
- // fit button before wiring. No synthetic graph coordinates are injected.
- await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
- await page.mouse.down()
- await page.mouse.move(viewport!.x + viewport!.width * (0.15 + column * 0.34),
- viewport!.y + viewport!.height * (0.08 + row * 0.31), { steps: 10 })
- await page.mouse.up()
- await fieldText(frame, /^节点名称$/, `闭环-${title}`)
- return id
- }
-
- async function connect(frame: Frame, from: string, port: string, to: string) {
- const root = blueprint(frame)
- await root.locator(`.bp-port-dot[data-node-id="${from}"][data-port-id="${port}"][data-direction="out"]`)
- .dragTo(root.locator(`.bp-port-dot[data-node-id="${to}"][data-port-id="in"][data-direction="in"]`))
- await expect.poll(() => frame.evaluate(({ from, port, to }) => (window as any).editorApp.blueprint.edges.some((e: any) =>
- e.from.node === from && e.from.port === port && e.to.node === to), { from, port, to })).toBe(true)
- }
-
- async function graphState(frame: Frame) {
- return frame.evaluate(() => {
- const b = (window as any).editorApp.blueprint
- return { nodes: b.nodes, edges: b.edges, running: b.running, logs: b.logs }
- })
- }
-
- async function resetBlueprint(frame: Frame) {
- await blueprint(frame).locator('[data-command="reset"]').click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.running)).toBe(false)
- }
-
- async function waitForFinished(frame: Frame) {
- await expect.poll(async () => {
- const state = await graphState(frame)
- return !state.running && state.logs.some((entry: any) => entry.message.startsWith('蓝图执行完成'))
- }, { timeout: 30_000 }).toBe(true)
- expect((await graphState(frame)).logs.filter((entry: any) => entry.level === 'error')).toEqual([])
- }
-
- test.afterEach(async ({ page }) => { await closeSession(page) })
-
- test('时间轴真实控件:轨道/关键帧/播放与曲线 5 插值,保存后重新加载一致', async ({ page }, info) => {
- test.setTimeout(240_000)
- await signIn(page)
- let frame = await openEditor(page, 'truck')
- const mesh = await selectMesh(frame)
- await activate(frame, 'timeline')
- const root = timeline(frame)
- const count = await frame.evaluate(() => (window as any).editorApp.timeline.tracks.length)
- await root.locator('[data-action="add-track"]').click()
- const disposable = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
- await root.locator('[data-action="remove-track"]').click()
- expect(await trackState(frame, disposable)).toBeNull()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.tracks.length)).toBe(count)
-
- await root.locator('[data-action="add-track"]').click()
- const id = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
- const options = await root.locator('[data-role="track-target"] option').evaluateAll(options => options.map(o => ({ value: (o as HTMLOptionElement).value, text: o.textContent })))
- const targetId = options.find(o => o.value === mesh.id)?.value || options.find(o => o.text?.includes(mesh.name))?.value
- expect(targetId, 'Selected real mesh must be offered by the target selector').toBeTruthy()
- await root.locator('[data-role="track-target"]').selectOption(targetId!)
- for (const property of ['rotation.x', 'rotation.y', 'rotation.z', 'position']) {
- await root.locator('[data-role="track-property"]').selectOption(property)
- expect((await trackState(frame, id)).property).toBe(property)
- }
- await root.locator('[data-role="loop"]').uncheck()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.loop)).toBe(false)
- await root.locator('[data-role="loop"]').check()
- await setRange(frame, '#timeline-root [data-role="zoom"]', '0.5')
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.zoom)).toBe(0.5)
-
- const lane = root.locator(`.timeline-lane[data-track-id="${id}"]`)
- await lane.scrollIntoViewIfNeeded()
- const laneBox = await lane.boundingBox()
- expect(laneBox).not.toBeNull()
- await lane.dblclick({ position: { x: laneBox!.width * 0.28, y: laneBox!.height / 2 } })
- let state = await trackState(frame, id)
- expect(state.keyframes).toHaveLength(3)
- const middleId = state.keyframes[1].id
- const beforeDrag = state.keyframes[1].time
- const key = root.locator(`.timeline-keyframe[data-key-id="${middleId}"]`)
- const keyBox = await key.boundingBox()
- await page.mouse.move(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2)
- await page.mouse.down()
- await page.mouse.move(laneBox!.x + laneBox!.width * 0.43, laneBox!.y + laneBox!.height / 2, { steps: 12 })
- await page.mouse.up()
- state = await trackState(frame, id)
- expect(state.keyframes.find((k: any) => k.id === middleId).time).toBeGreaterThan(beforeDrag)
-
- // A second temporary key exercises the actual delete button without deleting
- // the authored key whose value/curve will be verified after a network reload.
- await lane.dblclick({ position: { x: laneBox!.width * 0.65, y: laneBox!.height / 2 } })
- expect((await trackState(frame, id)).keyframes).toHaveLength(4)
- await root.locator('[data-action="delete"]').click()
- expect((await trackState(frame, id)).keyframes).toHaveLength(3)
- await root.locator('[data-action="stop"]').click()
- await root.locator('[data-action="next"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeGreaterThan(0)
- await root.locator('[data-action="previous"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
- await root.locator('[data-action="play"]').click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeGreaterThan(0.1)
- await root.locator('[data-action="pause"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.playing)).toBe(false)
- await root.locator(`[data-track-id="${id}"] [data-action="toggle-mute"]`).click()
- expect((await trackState(frame, id)).muted).toBe(true)
- expect(await frame.evaluate(id => Object.hasOwn((window as any).editorApp.timeline.evaluate(), id), id)).toBe(false)
- await root.locator(`[data-track-id="${id}"] [data-action="toggle-mute"]`).click()
- await root.locator('[data-action="stop"]').click()
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
- await shot(page, info, '21-truck-timeline-controls')
-
- await activate(frame, 'curve')
- await frame.locator(`[data-curve-track="${id}"]`).click()
- await frame.locator(`[data-curve-key="${middleId}"]`).click()
- const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration)
- const time = Math.min(2, duration / 2)
- await fillChanged(frame.locator('[data-curve-field="time"]'), String(time))
- await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="0"]'), '5')
- const samples: Record<string, number> = {}
- for (const easing of ['linear', 'easeInOut', 'easeIn', 'easeOut', 'step']) {
- await frame.locator('[data-curve-field="easing"]').selectOption(easing)
- expect((await trackState(frame, id)).keyframes.find((k: any) => k.id === middleId).easing).toBe(easing)
- samples[easing] = await frame.evaluate(({ id, time }) => (window as any).editorApp.timeline.evaluateTrack(id, time / 4)[0], { id, time })
- }
- expect(samples.linear).toBeCloseTo(1.25)
- expect(samples.easeIn).toBeLessThan(samples.linear)
- expect(samples.easeOut).toBeGreaterThan(samples.linear)
- expect(samples.step).toBe(0)
- await frame.locator('[data-curve-field="easing"]').selectOption('easeInOut')
- await frame.locator('[data-curve-axis]').selectOption('1')
- await frame.locator('[data-curve-axis]').selectOption('0')
- await shot(page, info, '22-truck-curve-value-five')
- await saveTruck(page, frame)
- await page.reload()
- frame = await readyFrame(page)
- const restored = await trackState(frame, id)
- expect(restored.targetId).toBe(targetId)
- expect(restored.property).toBe('position')
- expect(restored.muted).toBe(false)
- expect(restored.keyframes).toHaveLength(3)
- expect(restored.keyframes.find((k: any) => k.id === middleId)).toMatchObject({ time, value: [5, 0, 0], easing: 'easeInOut' })
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.loop)).toBe(true)
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.zoom)).toBe(0.5)
- authoredTrack = { id, middleId, time, targetId: targetId! }
- evidence('truck-timeline-curve', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
- featureIds: ['timeline.play', 'timeline.stop', 'timeline.loop', 'timeline.key-nav', 'timeline.add-track', 'timeline.remove-track', 'timeline.target', 'timeline.property', 'timeline.mute', 'timeline.add-key', 'timeline.delete-key', 'timeline.drag-key', 'timeline.zoom', 'timeline.curve', 'curve.time', 'curve.value', 'curve.easing'],
- scope: '真实控件和指针操作,运行时读取数值校验;curve.axis仅切换无数值独立断言,timeline.seek播放头与Alt吸附未在本例覆盖。', projectId: fixture().models.truck.id, actions: '真实 DOM 轨道/帧增删、关键帧指针拖移、播放控件、曲线输入和保存重开', restored, interpolationSamples: samples, excluded: ['未新增 solo 能力', '未将原生 GLB 动画片段当成多条编辑时间轴'] })
- })
-
- test('补充时间轴与蓝图工具:刻度/按键/记录/分量、基准匹配、节点搜索拖入与画布平移', async ({ page }, info) => {
- test.setTimeout(480_000)
- const token = await signIn(page)
- const copy = async (sourceId: string, label: string) => {
- const original = await call(`tran/v1/content/projects/${sourceId}`, token)
- expect(original.status).toBe(200)
- if (sourceId === '94' && !fixture().originals.bridge) {
- fs.writeFileSync(path.join(work, 'original-bridge.json'), JSON.stringify(original.data, null, 2))
- const data = fixture()
- data.originals.bridge = { id: sourceId, name: original.data.project.name, version: original.data.project.version,
- hash: crypto.createHash('sha256').update(JSON.stringify(original.data)).digest('hex') }
- writeFixture(data)
- }
- const result = await call(`tran/v1/content/projects/${sourceId}/copy`, token, {
- name: `闭环工具补充-${label}-${Date.now().toString(36)}`, version: original.data.project.version,
- })
- expect(result.status, result.message).toBe(200)
- const id = String(result.data.project.id)
- expect(['94', '156', '157']).not.toContain(id)
- const data = fixture()
- data.extraProjectIds = [...new Set([...(data.extraProjectIds || []), id])]
- data.blueprintToolCopies = { ...(data.blueprintToolCopies || {}), [label]: id }
- writeFixture(data)
- return id
- }
- const id = await copy('156', '卡车控件')
- let frame = await openEditor(page, id)
- const mesh = await selectMesh(frame)
- await activate(frame, 'timeline')
- await timeline(frame).locator('[data-action="stop"]').click()
- await timeline(frame).locator('[data-action="add-track"]').click()
- const trackId = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
- const targetOptions = await timeline(frame).locator('[data-role="track-target"] option').evaluateAll(options => options.map(o => ({ value: (o as HTMLOptionElement).value, text: o.textContent })))
- const targetOption = targetOptions.find(o => o.value === mesh.id) || targetOptions.find(o => o.text?.includes(mesh.name))
- expect(targetOption).toBeTruthy()
- await timeline(frame).locator('[data-role="track-target"]').selectOption(targetOption!.value)
- await timeline(frame).locator('[data-role="track-property"]').selectOption('position')
- await setRange(frame, '#timeline-root [data-role="zoom"]', '0.5')
- const lane = timeline(frame).locator(`.timeline-lane[data-track-id="${trackId}"]`)
- const laneBox = await lane.boundingBox()
- await lane.dblclick({ position: { x: laneBox!.width / 4, y: laneBox!.height / 2 } })
- const middle = (await trackState(frame, trackId)).keyframes[1]
- await activate(frame, 'curve')
- await frame.locator(`[data-curve-track="${trackId}"]`).click()
- await frame.locator(`[data-curve-key="${middle.id}"]`).click()
- await fillChanged(frame.locator('[data-curve-field="time"]'), '2')
- await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="0"]'), '5')
- await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="1"]'), '-2')
- await frame.locator('[data-curve-field="easing"]').selectOption('linear')
- const xPath = await frame.locator('.curve-path').getAttribute('points')
- await frame.locator('[data-curve-axis]').selectOption('1')
- const yPath = await frame.locator('.curve-path').getAttribute('points')
- expect(yPath).not.toEqual(xPath)
- expect((await trackState(frame, trackId)).keyframes.find((k: any) => k.id === middle.id).value).toEqual([5, -2, 0])
- await expect(frame.locator('.curve-label')).toContainText('-2.000')
- await shot(page, info, '28-curve-axis-y-negative')
- await frame.locator('[data-curve-axis]').selectOption('0')
- expect(await frame.locator('.curve-path').getAttribute('points')).toBe(xPath)
-
- await activate(frame, 'timeline')
- const ruler = timeline(frame).locator('[data-role="ruler"]')
- const rulerBox = await ruler.boundingBox()
- await ruler.click({ position: { x: rulerBox!.width / 8, y: rulerBox!.height / 2 } })
- const initialTime = await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)
- expect(initialTime).toBeCloseTo(1, 1)
- await timeline(frame).locator('[data-action="pause"]').focus()
- await page.keyboard.press('Shift+ArrowRight')
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeCloseTo(initialTime + 1, 5)
- await page.keyboard.press('ArrowLeft')
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeCloseTo(initialTime + 1 - 1 / 30, 5)
- await page.keyboard.press('Shift+ArrowLeft')
- const pose = await frame.evaluate(trackId => {
- const e = (window as any).editorApp, track = e.timeline.getTrack(trackId), target = e.modelTargets.get(track.targetId)
- return { time: e.timeline.currentTime, value: e.timeline.evaluateTrack(trackId), actual: target.position.toArray(), base: target.userData.timelineBase.position }
- }, trackId)
- expect(pose.time).toBeCloseTo(initialTime - 1 / 30, 5)
- pose.actual.forEach((value: number, i: number) => expect(value - pose.base[i]).toBeCloseTo(pose.value[i], 5))
- await expect(timeline(frame).locator('[data-role="timecode"]')).not.toContainText('00:00.000 /')
- await frame.locator('[data-action="add-key"]').click()
- const recorded = (await trackState(frame, trackId)).keyframes.find((k: any) => Math.abs(k.time - pose.time) < 0.00001)
- expect(recorded).toBeTruthy()
- recorded.value.forEach((value: number, i: number) => expect(value).toBeCloseTo(pose.value[i], 5))
- expect((await trackState(frame, trackId)).keyframes).toHaveLength(4)
- await shot(page, info, '29-timeline-seek-record')
-
- const beforeNoMatch = await frame.evaluate(() => ({ timeline: (window as any).editorApp.timeline.toJSON(), blueprint: (window as any).editorApp.blueprint.toJSON() }))
- await frame.locator('[data-action="load-baseline-animation"]').click()
- await expect(frame.locator('.toast').filter({ hasText: '没有可自动匹配的基准动作' })).toBeVisible()
- expect(await frame.evaluate(() => ({ timeline: (window as any).editorApp.timeline.toJSON(), blueprint: (window as any).editorApp.blueprint.toJSON() }))).toEqual(beforeNoMatch)
-
- await activate(frame, 'blueprint')
- const bp = blueprint(frame)
- await bp.locator('.bp-search-input').fill('条件判断')
- await expect(bp.locator('.bp-palette-item')).toHaveCount(1)
- await expect(bp.locator('.bp-palette-item')).toContainText('条件判断')
- await bp.locator('.bp-search-input').fill('')
- await expect(bp.locator('.bp-palette-item')).toHaveCount(7)
- const beforeAdd = await graphState(frame)
- const historyIndex = await frame.evaluate(() => (window as any).editorApp.history.index)
- await bp.locator('[data-command="add"]').click()
- const added = (await graphState(frame)).nodes.filter((n: any) => !beforeAdd.nodes.some((old: any) => old.id === n.id))
- expect(added).toHaveLength(1)
- expect(added[0].type).toBe('message')
- expect(await frame.evaluate(() => (window as any).editorApp.history.index)).toBeGreaterThan(historyIndex)
- const viewport = bp.locator('.bp-viewport'), viewportBox = await viewport.boundingBox()
- await bp.locator('.bp-palette-item').filter({ hasText: /^◷等待/ }).dragTo(viewport, {
- targetPosition: { x: viewportBox!.width * 0.1, y: viewportBox!.height * 0.15 },
- })
- const dragged = (await graphState(frame)).nodes.filter((n: any) => !beforeAdd.nodes.some((old: any) => old.id === n.id) && n.id !== added[0].id)
- expect(dragged).toHaveLength(1)
- expect(dragged[0].type).toBe('wait')
- const graphBeforeCanvas = await frame.evaluate(() => {
- const b = (window as any).editorApp.blueprint
- return { nodes: b.nodes, edges: b.edges, viewport: b.viewport }
- })
- const blank = await viewport.evaluate(element => {
- const rect = element.getBoundingClientRect()
- for (const x of [0.9, 0.1, 0.5]) for (const y of [0.85, 0.65, 0.25]) {
- const hit = document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y)
- if (hit && !hit.closest('.bp-node,.bp-edge')) return { x: rect.width * x, y: rect.height * y }
- }
- return null
- })
- expect(blank).not.toBeNull()
- await bp.focus()
- await page.keyboard.down('Space')
- await page.mouse.move(viewportBox!.x + blank!.x, viewportBox!.y + blank!.y)
- await page.mouse.down()
- await page.mouse.move(viewportBox!.x + blank!.x - 45, viewportBox!.y + blank!.y - 25, { steps: 8 })
- await page.mouse.up()
- await page.keyboard.up('Space')
- const afterPan = await frame.evaluate(() => (window as any).editorApp.blueprint.viewport)
- expect(afterPan.x).toBeCloseTo(graphBeforeCanvas.viewport.x - 45, 1)
- expect(afterPan.y).toBeCloseTo(graphBeforeCanvas.viewport.y - 25, 1)
- await page.mouse.wheel(0, -120)
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.viewport.zoom)).toBeGreaterThan(afterPan.zoom)
- await bp.locator('[data-command="frame"]').click()
- const graphAfterCanvas = await graphState(frame)
- expect(graphAfterCanvas.nodes).toEqual(graphBeforeCanvas.nodes)
- expect(graphAfterCanvas.edges).toEqual(graphBeforeCanvas.edges)
- await shot(page, info, '30-blueprint-palette-canvas')
- await saveTruck(page, frame, id)
- await page.reload(); frame = await readyFrame(page)
- expect((await trackState(frame, trackId)).keyframes.find((k: any) => k.id === recorded.id)).toEqual(recorded)
- expect((await graphState(frame)).nodes.some((n: any) => n.id === added[0].id)).toBe(true)
- expect((await graphState(frame)).nodes.some((n: any) => n.id === dragged[0].id)).toBe(true)
-
- const bridgeId = await copy('94', '基准匹配')
- frame = await openEditor(page, bridgeId)
- const identity = await frame.evaluate(() => ({ name: (window as any).editorApp.projectName, model: (window as any).editorApp.activeDescriptor.id }))
- await frame.locator('[data-action="focus"]').first().click()
- await activate(frame, 'timeline')
- // Source 94 already contains the generated baseline. Persist a real extra
- // track first, so reloading the baseline exercises a changed server draft
- // instead of correctly taking the unchanged-save fast path.
- await timeline(frame).locator('[data-action="add-track"]').click()
- const replacedTrack = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
- await saveTruck(page, frame, bridgeId)
- const savedBaseline = page.waitForResponse(response => response.request().method() === 'PUT'
- && new URL(response.url()).pathname.endsWith(`/content/projects/${bridgeId}`), { timeout: 120_000 })
- await frame.locator('[data-action="load-baseline-animation"]').click()
- expect((await savedBaseline).status()).toBe(200)
- await expect.poll(() => frame.evaluate(() => !(window as any).editorApp.apiCoverSavePending)).toBe(true)
- const baseline = await frame.evaluate(() => {
- const e = (window as any).editorApp
- return { name: e.projectName, timeline: e.timeline.toJSON(), resolved: e.timeline.tracks.every((t: any) => e.modelTargets.has(t.targetId)) }
- })
- expect(baseline.name).toBe(identity.name)
- expect(baseline.timeline.tracks.length).toBeGreaterThan(1)
- expect(baseline.timeline.tracks.some((t: any) => t.id === replacedTrack)).toBe(false)
- expect(baseline.resolved).toBe(true)
- await page.reload(); frame = await readyFrame(page)
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.toJSON())).toEqual(baseline.timeline)
- await shot(page, info, '31-bridge-baseline-reopened')
- evidence('truck-blueprint-tool-supplement', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
- featureIds: ['timeline.seek', 'timeline.record', 'timeline.baseline', 'curve.axis', 'blueprint.palette', 'blueprint.add', 'blueprint.canvas'],
- projectIds: [id, bridgeId], pose, recorded, curveAxis: { xAndYPathsDiffer: xPath !== yPath, values: [5, -2, 0] },
- noMatchPreserved: true, baseline, addedNode: added[0], draggedNode: dragged[0], canvasPreservedGraph: true,
- scope: '真实模型编辑参数/基准轨道生成验收,不构成装备操作或维修指导。桥梁原工程94仅GET和copy。' })
- })
-
- test('蓝图真实节点库与端口拖线:七种参数、内部边复制/剪切、撤销重做及保存', async ({ page }, info) => {
- test.setTimeout(300_000)
- await signIn(page)
- const frame = await openEditor(page, 'truck')
- const mesh = await selectMesh(frame)
- targetAlias = mesh.id || mesh.name
- expect(targetAlias).toBeTruthy()
- await activate(frame, 'timeline')
- await timeline(frame).locator('[data-action="stop"]').click()
- await activate(frame, 'blueprint')
- const root = blueprint(frame)
- await root.focus()
- await page.keyboard.press('Control+a')
- await page.keyboard.press('Delete')
- expect((await graphState(frame)).nodes).toHaveLength(0)
- await expect(root.locator('.bp-empty-hint')).toBeVisible()
- const previousZoom = await frame.evaluate(() => (window as any).editorApp.blueprint.viewport.zoom)
- await root.locator('.bp-viewport').hover()
- // 80% leaves a visible gap between the three 210px nodes in this viewport.
- await page.mouse.wheel(0, Math.log(previousZoom / 0.8) / 0.0012)
- await expect.poll(() => frame.evaluate(() => Math.round((window as any).editorApp.blueprint.viewport.zoom * 1000))).toBe(800)
- const ids = {} as NodeIds
- ids.trigger = await addNode(page, frame, '点击触发', 'trigger-click', 0, 0)
- await (await bpField(frame, /^事件$/)).selectOption('click')
- await fieldText(frame, /^目标对象$/, targetAlias)
- ids.play = await addNode(page, frame, '播放时间轴', 'play-timeline', 1, 0)
- await fieldText(frame, /^(动作名称|动画片段)/, '牛奶卡车曲线联动')
- const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration)
- await fieldText(frame, /^起始时间$/, String(duration))
- await fieldText(frame, /^播放速度$/, '2')
- await (await bpField(frame, /^等待播完$/)).check()
- ids.wait = await addNode(page, frame, '等待', 'wait', 2, 0)
- await fieldText(frame, /^时长/, '100')
- ids.highlight = await addNode(page, frame, '高亮部件', 'highlight', 0, 1)
- await fieldText(frame, /^目标对象$/, targetAlias)
- await (await bpField(frame, /^高亮颜色$/)).evaluate(input => {
- (input as HTMLInputElement).value = '#ff8800'
- input.dispatchEvent(new Event('input', { bubbles: true }))
- input.dispatchEvent(new Event('change', { bubbles: true }))
- })
- await (await bpField(frame, /^呼吸效果$/)).uncheck()
- ids.message = await addNode(page, frame, '操作提示', 'message', 1, 1)
- await fieldText(frame, /^标题$/, '卡车模型闭环提示')
- await fieldText(frame, /^提示内容$/, '检查真实目标高亮、时间轴完成和条件出口。')
- await (await bpField(frame, /^级别$/)).selectOption('success')
- ids.condition = await addNode(page, frame, '条件判断', 'condition', 2, 1)
- await fieldText(frame, /^变量$/, 'timelineComplete')
- await (await bpField(frame, /^运算符$/)).selectOption('==')
- await fieldText(frame, /^比较值$/, 'true')
- ids.finish = await addNode(page, frame, '流程完成', 'finish', 1, 2)
- await (await bpField(frame, /^结果$/)).selectOption('success')
- await fieldText(frame, /^完成提示$/, '牛奶卡车蓝图验证完成')
- await root.locator('[data-command="frame"]').click()
- await connect(frame, ids.trigger, 'then', ids.play)
- await connect(frame, ids.play, 'then', ids.wait)
- await connect(frame, ids.wait, 'then', ids.highlight)
- await connect(frame, ids.highlight, 'then', ids.message)
- await connect(frame, ids.message, 'then', ids.condition)
- await connect(frame, ids.condition, 'true', ids.finish)
- expect((await graphState(frame)).edges).toHaveLength(6)
- await expect(root.locator('.bp-empty-hint')).toBeHidden()
- expect(await root.locator('.bp-empty-hint').evaluate(element => getComputedStyle(element).display)).toBe('none')
- const parameters = (await graphState(frame)).nodes
- const params = (id: string) => parameters.find((n: any) => n.id === id).params
- expect(params(ids.trigger)).toMatchObject({ event: 'click', target: targetAlias })
- expect(params(ids.play)).toMatchObject({ clip: '牛奶卡车曲线联动', from: duration, speed: 2, waitForEnd: true })
- expect(params(ids.wait)).toMatchObject({ duration: 100 })
- expect(params(ids.highlight)).toMatchObject({ target: targetAlias, color: '#ff8800', pulse: false })
- expect(params(ids.message)).toMatchObject({ title: '卡车模型闭环提示', text: '检查真实目标高亮、时间轴完成和条件出口。', level: 'success' })
- expect(params(ids.condition)).toMatchObject({ variable: 'timelineComplete', operator: '==', value: true })
- expect(params(ids.finish)).toMatchObject({ result: 'success', text: '牛奶卡车蓝图验证完成' })
- await shot(page, info, '23-truck-blueprint-seven-nodes')
-
- // Clipboard is driven by real shortcuts scoped to the blueprint. All seven
- // copied nodes must keep their six internal edges, with fresh endpoint IDs.
- await root.focus()
- await page.keyboard.press('Control+a')
- await page.keyboard.press('Control+c')
- await page.keyboard.press('Control+v')
- const copied = await graphState(frame)
- expect(copied.nodes).toHaveLength(14)
- expect(copied.edges).toHaveLength(12)
- const copyIds = copied.nodes.filter((n: any) => !Object.values(ids).includes(n.id)).map((n: any) => n.id)
- expect(copied.edges.filter((e: any) => copyIds.includes(e.from.node) && copyIds.includes(e.to.node))).toHaveLength(6)
- await page.keyboard.press('Control+x')
- expect((await graphState(frame)).nodes).toHaveLength(7)
- await page.keyboard.press('Control+v')
- expect((await graphState(frame)).edges).toHaveLength(12)
- await page.keyboard.press('Delete')
- expect((await graphState(frame)).nodes).toHaveLength(7)
- expect((await graphState(frame)).edges).toHaveLength(6)
- await frame.locator('[data-action="undo"]').first().click()
- await expect.poll(() => frame.evaluate(() => ({ count: (window as any).editorApp.blueprint.nodes.length, locked: (window as any).editorApp.history.locked })), { timeout: 60_000 }).toEqual({ count: 14, locked: false })
- await frame.locator('[data-action="redo"]').first().click()
- await expect.poll(() => frame.evaluate(() => ({ count: (window as any).editorApp.blueprint.nodes.length, locked: (window as any).editorApp.history.locked })), { timeout: 60_000 }).toEqual({ count: 7, locked: false })
- await root.locator('[data-command="frame"]').click()
-
- await root.locator('[data-command="run"]').click()
- await expect(frame.locator('[data-modal-close]')).toBeVisible()
- await shot(page, info, '24-truck-blueprint-real-message')
- await frame.locator('[data-modal-close]').click()
- await waitForFinished(frame)
- const completed = await graphState(frame)
- for (const id of Object.values(ids)) expect(completed.logs.some((l: any) => l.nodeId === id && l.message.startsWith('执行节点'))).toBe(true)
- expect(completed.logs.some((l: any) => l.message.includes('TRUE'))).toBe(true)
- await shot(page, info, '25-truck-blueprint-completed-log')
-
- // A long real wait allows a deterministic toolbar cancellation assertion.
- await selectNode(frame, ids.wait)
- await fieldText(frame, /^时长/, '30000')
- await root.locator('[data-command="run"]').click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.activeNodeId)).toBe(ids.wait)
- await root.locator('[data-command="stop"]').click()
- expect((await graphState(frame)).running).toBe(false)
- expect((await graphState(frame)).logs.some((l: any) => l.message.includes('已停止'))).toBe(true)
- await resetBlueprint(frame)
- expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
- expect(await frame.evaluate(() => (window as any).editorApp.activeHighlightMaterials.size)).toBe(0)
- await selectNode(frame, ids.wait)
- await fieldText(frame, /^时长/, '100')
- await saveTruck(page, frame)
- authoredGraph = ids
- evidence('truck-blueprint-ui', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
- featureIds: ['blueprint.parameters', 'blueprint.move', 'blueprint.connect', 'blueprint.delete', 'blueprint.copy', 'blueprint.run', 'blueprint.stop', 'blueprint.reset', 'blueprint.timeline', 'blueprint.wait', 'blueprint.highlight', 'blueprint.message', 'blueprint.finish', 'project.undo', 'project.redo'],
- scope: '节点库点选添加及实际拖动/接线/执行;不把点选当成节点拖入,不把适应画布当成鼠标平移缩放全部已验。', projectId: fixture().models.truck.id, graph: await graphState(frame), completedLog: completed.logs,
- actions: '真实节点库/参数控件/端口拖线/复制剪切粘贴/删除/撤销重做/运行停止重置/保存',
- boundaries: '200 步循环保护由独立 Node 回归覆盖;本浏览器流程不注入假图或替换执行 handler。' })
- })
-
- test('已保存蓝图:条件出口与目标匹配 runtime 检查,真实模型单击/双击/进入预览自动触发', async ({ page }, info) => {
- test.setTimeout(240_000)
- await signIn(page)
- const frame = await openEditor(page, 'truck')
- const ids = authoredGraph
- expect(ids).toBeTruthy()
- const restored = await graphState(frame)
- expect(restored.nodes.map((n: any) => n.id).sort()).toEqual(Object.values(ids).sort())
- expect(restored.edges).toHaveLength(6)
- if (authoredTrack) expect((await trackState(frame, authoredTrack.id)).keyframes.find((k: any) => k.id === authoredTrack.middleId).value[0]).toBe(5)
- await activate(frame, 'blueprint')
- const root = blueprint(frame)
- await root.locator('[data-command="frame"]').click()
- await resetBlueprint(frame)
-
- // Explicit runtime-only assertions on the graph authored with UI in test 2.
- // They supplement, rather than stand in for, the canvas actions below.
- const branches = await frame.evaluate(async ({ condition, finish }) => {
- const b = (window as any).editorApp.blueprint
- const one = async (value: boolean) => {
- const start = b.logs.length
- const result = await b.run({ startNodeId: condition, variables: { timelineComplete: value } })
- const logs = b.logs.slice(start)
- return { result, visitedFinish: logs.some((l: any) => l.nodeId === finish && l.message.startsWith('执行节点')), logs }
- }
- return { falseBranch: await one(false), trueBranch: await one(true) }
- }, { condition: ids.condition, finish: ids.finish })
- expect(branches.falseBranch.result).toBe(true)
- expect(branches.falseBranch.visitedFinish).toBe(false)
- expect(branches.trueBranch.visitedFinish).toBe(true)
- const mismatch = await frame.evaluate(async () => {
- const b = (window as any).editorApp.blueprint, count = b.logs.length
- const result = await b.trigger('click', { object: '__different_real_target__' })
- return { result, started: b.logs.slice(count).some((l: any) => l.message.startsWith('开始执行')) }
- })
- expect(mismatch.started).toBe(false)
- await shot(page, info, '26-truck-condition-branches-runtime')
-
- // Bind to the actual model root alias so any visible child pixel can trigger
- // it. The pointer locations are found by read-only raycasting; the real
- // renderer's pointer handlers produce the payload (no fake trigger dispatch).
- const rootAlias = await frame.evaluate(() => {
- const model = (window as any).editorApp.modelPivot
- return model.userData.editorId || model.name
- })
- expect(rootAlias).toBeTruthy()
- await selectNode(frame, ids.trigger)
- await fieldText(frame, /^目标对象$/, rootAlias)
- const events: unknown[] = []
- for (const event of ['click', 'double-click', 'auto']) {
- await selectNode(frame, ids.trigger)
- await (await bpField(frame, /^事件$/)).selectOption(event)
- await resetBlueprint(frame)
- await frame.locator('[data-action="preview"]').first().click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(true)
- if (event !== 'auto') {
- const point = await frame.evaluate(() => {
- const e = (window as any).editorApp
- const rect = e.renderer.domElement.getBoundingClientRect()
- const ray = new e.raycaster.constructor()
- const pointer = e.pointer.clone()
- for (let iy = 2; iy < 19; iy++) for (let ix = 2; ix < 19; ix++) {
- const x = ix / 20, y = iy / 20
- if (document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y) !== e.renderer.domElement) continue
- pointer.set(x * 2 - 1, -(y * 2 - 1))
- ray.setFromCamera(pointer, e.camera)
- const hit = ray.intersectObjects([e.modelPivot], true).find((hit: any) => {
- for (let o = hit.object; o; o = o.parent) if (!o.visible) return false
- return true
- })
- if (hit) return { x: x * rect.width, y: y * rect.height, mesh: hit.object.name }
- }
- return null
- })
- expect(point, 'A visible, unobstructed real truck mesh pixel must be raycastable').not.toBeNull()
- const canvas = frame.locator('#render-host canvas')
- if (event === 'click') await canvas.click({ position: point! })
- else await canvas.dblclick({ position: point!, delay: 70 })
- }
- await expect(frame.locator('[data-modal-close]')).toBeVisible()
- await frame.locator('[data-modal-close]').click()
- await waitForFinished(frame)
- const state = await graphState(frame)
- expect(state.logs.filter((l: any) => l.message.startsWith('开始执行'))).toHaveLength(1)
- expect(state.logs.some((l: any) => l.nodeId === ids.finish && l.message.startsWith('执行节点'))).toBe(true)
- events.push({ event, starts: 1, finished: true, source: event === 'auto' ? '真实点击进入预览' : '真实 renderer canvas 指针事件', logs: state.logs })
- await shot(page, info, `27-truck-preview-${event}`)
- await frame.locator('.preview-exit[data-action="preview"]').click()
- await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(false)
- }
- // Keep a normal click trigger in the saved copy; no test-only handler/data is
- // stored. Reopen verifies graph parameters survived the actual backend save.
- await selectNode(frame, ids.trigger)
- await (await bpField(frame, /^事件$/)).selectOption('click')
- await resetBlueprint(frame)
- await saveTruck(page, frame)
- await page.reload()
- const reopened = await readyFrame(page)
- const finalGraph = await graphState(reopened)
- expect(finalGraph.nodes.find((n: any) => n.id === ids.trigger).params).toMatchObject({ event: 'click', target: rootAlias })
- expect(finalGraph.edges).toHaveLength(6)
- evidence('truck-blueprint-events', { status: 'PASS', kind: 'e2e', testTitle: info.title,
- featureIds: ['blueprint.click', 'blueprint.double-click', 'blueprint.auto', 'view.preview'], projectId: fixture().models.truck.id,
- runtimeSupplement: { status: 'PASS', kind: 'runtime', featureIds: ['blueprint.condition'], branches, mismatch }, browserEvents: events,
- persistedNodeCount: finalGraph.nodes.length, persistedEdgeCount: finalGraph.edges.length,
- scope: '执行使用正式模型编辑器的真实模型/时间轴/高亮/提示 handler;不等同于学员端任务下发。' })
- })
|