Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 

637 lignes
39 KiB

  1. import { test, expect, type Frame, type Locator, type Page } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import crypto from 'node:crypto'
  5. import { signIn, openEditor, readyFrame, selectMesh, setRange, saveEditor, shot, closeSession, fixture, evidence, call, work, writeFixture } from './model-editor-closure-helpers'
  6. // Real backend + real editor. This file owns only the copied truck fixture.
  7. // evaluate() reads editor state, except explicitly labelled runtime branch checks.
  8. // Auth state stays in the helper/browser; no trace or storageState is exported.
  9. test.describe.configure({ mode: 'serial' })
  10. type NodeIds = Record<'trigger' | 'play' | 'wait' | 'highlight' | 'message' | 'condition' | 'finish', string>
  11. let authoredGraph: NodeIds
  12. let targetAlias: string
  13. let authoredTrack: { id: string; middleId: string; time: number; targetId: string }
  14. const timeline = (frame: Frame) => frame.locator('#timeline-root')
  15. const blueprint = (frame: Frame) => frame.locator('#blueprint-root')
  16. const node = (frame: Frame, id: string) => blueprint(frame).locator(`.bp-node[data-node-id="${id}"]`)
  17. async function fillChanged(input: Locator, value: string) {
  18. await input.fill(value)
  19. await input.press('Tab')
  20. }
  21. async function activate(frame: Frame, name: string) {
  22. await frame.locator(`[data-bottom-tab="${name}"]`).click()
  23. await expect(frame.locator(`.editor-mode[data-mode="${name}"]`)).toHaveClass(/active/)
  24. }
  25. async function trackState(frame: Frame, id: string) {
  26. return frame.evaluate(id => (window as any).editorApp.timeline.getTrack(id), id)
  27. }
  28. async function saveTruck(page: Page, frame: Frame, id = fixture().models.truck.id) {
  29. expect([94, 156, 157]).not.toContain(Number(id))
  30. const response = page.waitForResponse(response => response.request().method() === 'PUT' &&
  31. new URL(response.url()).pathname.endsWith(`/content/projects/${id}`), { timeout: 90_000 })
  32. await saveEditor(frame)
  33. expect((await response).status()).toBe(200)
  34. await expect.poll(() => frame.evaluate(() => {
  35. const e = (window as any).editorApp
  36. return e.apiState === 'ready' && !e.apiSession.savePending && !e.apiCoverSavePending
  37. }), { timeout: 90_000 }).toBe(true)
  38. }
  39. async function bpField(frame: Frame, label: RegExp) {
  40. return blueprint(frame).locator('.bp-field').filter({
  41. has: frame.locator('.bp-field-label').filter({ hasText: label }),
  42. }).locator('input,select,textarea')
  43. }
  44. async function fieldText(frame: Frame, label: RegExp, value: string) {
  45. await fillChanged(await bpField(frame, label), value)
  46. }
  47. async function selectNode(frame: Frame, id: string) {
  48. await node(frame, id).locator('.bp-node-header').click()
  49. await expect(node(frame, id)).toHaveClass(/bp-selected/)
  50. }
  51. async function addNode(page: Page, frame: Frame, title: string, type: string, column: number, row: number) {
  52. await blueprint(frame).locator('.bp-palette-item').filter({ hasText: title }).click()
  53. const id = await frame.evaluate(() => [...(window as any).editorApp.blueprint.selection][0] as string)
  54. expect(await frame.evaluate(id => (window as any).editorApp.blueprint.nodes.find((n: any) => n.id === id).type, id)).toBe(type)
  55. const header = node(frame, id).locator('.bp-node-header')
  56. const box = await header.boundingBox()
  57. const viewport = await blueprint(frame).locator('.bp-viewport').boundingBox()
  58. expect(box).not.toBeNull(); expect(viewport).not.toBeNull()
  59. // Actual pointer drag. Arrange headers in a 3 x 3 grid, then use the editor's
  60. // fit button before wiring. No synthetic graph coordinates are injected.
  61. await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2)
  62. await page.mouse.down()
  63. await page.mouse.move(viewport!.x + viewport!.width * (0.15 + column * 0.34),
  64. viewport!.y + viewport!.height * (0.08 + row * 0.31), { steps: 10 })
  65. await page.mouse.up()
  66. await fieldText(frame, /^节点名称$/, `闭环-${title}`)
  67. return id
  68. }
  69. async function connect(frame: Frame, from: string, port: string, to: string) {
  70. const root = blueprint(frame)
  71. await root.locator(`.bp-port-dot[data-node-id="${from}"][data-port-id="${port}"][data-direction="out"]`)
  72. .dragTo(root.locator(`.bp-port-dot[data-node-id="${to}"][data-port-id="in"][data-direction="in"]`))
  73. await expect.poll(() => frame.evaluate(({ from, port, to }) => (window as any).editorApp.blueprint.edges.some((e: any) =>
  74. e.from.node === from && e.from.port === port && e.to.node === to), { from, port, to })).toBe(true)
  75. }
  76. async function graphState(frame: Frame) {
  77. return frame.evaluate(() => {
  78. const b = (window as any).editorApp.blueprint
  79. return { nodes: b.nodes, edges: b.edges, running: b.running, logs: b.logs }
  80. })
  81. }
  82. async function resetBlueprint(frame: Frame) {
  83. await blueprint(frame).locator('[data-command="reset"]').click()
  84. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.running)).toBe(false)
  85. }
  86. async function waitForFinished(frame: Frame) {
  87. await expect.poll(async () => {
  88. const state = await graphState(frame)
  89. return !state.running && state.logs.some((entry: any) => entry.message.startsWith('蓝图执行完成'))
  90. }, { timeout: 30_000 }).toBe(true)
  91. expect((await graphState(frame)).logs.filter((entry: any) => entry.level === 'error')).toEqual([])
  92. }
  93. test.afterEach(async ({ page }) => { await closeSession(page) })
  94. test('时间轴真实控件:轨道/关键帧/播放与曲线 5 插值,保存后重新加载一致', async ({ page }, info) => {
  95. test.setTimeout(240_000)
  96. await signIn(page)
  97. let frame = await openEditor(page, 'truck')
  98. const mesh = await selectMesh(frame)
  99. await activate(frame, 'timeline')
  100. const root = timeline(frame)
  101. const count = await frame.evaluate(() => (window as any).editorApp.timeline.tracks.length)
  102. await root.locator('[data-action="add-track"]').click()
  103. const disposable = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
  104. await root.locator('[data-action="remove-track"]').click()
  105. expect(await trackState(frame, disposable)).toBeNull()
  106. expect(await frame.evaluate(() => (window as any).editorApp.timeline.tracks.length)).toBe(count)
  107. await root.locator('[data-action="add-track"]').click()
  108. const id = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
  109. const options = await root.locator('[data-role="track-target"] option').evaluateAll(options => options.map(o => ({ value: (o as HTMLOptionElement).value, text: o.textContent })))
  110. const targetId = options.find(o => o.value === mesh.id)?.value || options.find(o => o.text?.includes(mesh.name))?.value
  111. expect(targetId, 'Selected real mesh must be offered by the target selector').toBeTruthy()
  112. await root.locator('[data-role="track-target"]').selectOption(targetId!)
  113. for (const property of ['rotation.x', 'rotation.y', 'rotation.z', 'position']) {
  114. await root.locator('[data-role="track-property"]').selectOption(property)
  115. expect((await trackState(frame, id)).property).toBe(property)
  116. }
  117. await root.locator('[data-role="loop"]').uncheck()
  118. expect(await frame.evaluate(() => (window as any).editorApp.timeline.loop)).toBe(false)
  119. await root.locator('[data-role="loop"]').check()
  120. await setRange(frame, '#timeline-root [data-role="zoom"]', '0.5')
  121. expect(await frame.evaluate(() => (window as any).editorApp.timeline.zoom)).toBe(0.5)
  122. const lane = root.locator(`.timeline-lane[data-track-id="${id}"]`)
  123. await lane.scrollIntoViewIfNeeded()
  124. const laneBox = await lane.boundingBox()
  125. expect(laneBox).not.toBeNull()
  126. await lane.dblclick({ position: { x: laneBox!.width * 0.28, y: laneBox!.height / 2 } })
  127. let state = await trackState(frame, id)
  128. expect(state.keyframes).toHaveLength(3)
  129. const middleId = state.keyframes[1].id
  130. const beforeDrag = state.keyframes[1].time
  131. const key = root.locator(`.timeline-keyframe[data-key-id="${middleId}"]`)
  132. const keyBox = await key.boundingBox()
  133. await page.mouse.move(keyBox!.x + keyBox!.width / 2, keyBox!.y + keyBox!.height / 2)
  134. await page.mouse.down()
  135. await page.mouse.move(laneBox!.x + laneBox!.width * 0.43, laneBox!.y + laneBox!.height / 2, { steps: 12 })
  136. await page.mouse.up()
  137. state = await trackState(frame, id)
  138. expect(state.keyframes.find((k: any) => k.id === middleId).time).toBeGreaterThan(beforeDrag)
  139. // A second temporary key exercises the actual delete button without deleting
  140. // the authored key whose value/curve will be verified after a network reload.
  141. await lane.dblclick({ position: { x: laneBox!.width * 0.65, y: laneBox!.height / 2 } })
  142. expect((await trackState(frame, id)).keyframes).toHaveLength(4)
  143. await root.locator('[data-action="delete"]').click()
  144. expect((await trackState(frame, id)).keyframes).toHaveLength(3)
  145. await root.locator('[data-action="stop"]').click()
  146. await root.locator('[data-action="next"]').click()
  147. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeGreaterThan(0)
  148. await root.locator('[data-action="previous"]').click()
  149. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
  150. await root.locator('[data-action="play"]').click()
  151. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeGreaterThan(0.1)
  152. await root.locator('[data-action="pause"]').click()
  153. expect(await frame.evaluate(() => (window as any).editorApp.timeline.playing)).toBe(false)
  154. await root.locator(`[data-track-id="${id}"] [data-action="toggle-mute"]`).click()
  155. expect((await trackState(frame, id)).muted).toBe(true)
  156. expect(await frame.evaluate(id => Object.hasOwn((window as any).editorApp.timeline.evaluate(), id), id)).toBe(false)
  157. await root.locator(`[data-track-id="${id}"] [data-action="toggle-mute"]`).click()
  158. await root.locator('[data-action="stop"]').click()
  159. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
  160. await shot(page, info, '21-truck-timeline-controls')
  161. await activate(frame, 'curve')
  162. await frame.locator(`[data-curve-track="${id}"]`).click()
  163. await frame.locator(`[data-curve-key="${middleId}"]`).click()
  164. const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration)
  165. const time = Math.min(2, duration / 2)
  166. await fillChanged(frame.locator('[data-curve-field="time"]'), String(time))
  167. await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="0"]'), '5')
  168. const samples: Record<string, number> = {}
  169. for (const easing of ['linear', 'easeInOut', 'easeIn', 'easeOut', 'step']) {
  170. await frame.locator('[data-curve-field="easing"]').selectOption(easing)
  171. expect((await trackState(frame, id)).keyframes.find((k: any) => k.id === middleId).easing).toBe(easing)
  172. samples[easing] = await frame.evaluate(({ id, time }) => (window as any).editorApp.timeline.evaluateTrack(id, time / 4)[0], { id, time })
  173. }
  174. expect(samples.linear).toBeCloseTo(1.25)
  175. expect(samples.easeIn).toBeLessThan(samples.linear)
  176. expect(samples.easeOut).toBeGreaterThan(samples.linear)
  177. expect(samples.step).toBe(0)
  178. await frame.locator('[data-curve-field="easing"]').selectOption('easeInOut')
  179. await frame.locator('[data-curve-axis]').selectOption('1')
  180. await frame.locator('[data-curve-axis]').selectOption('0')
  181. await shot(page, info, '22-truck-curve-value-five')
  182. await saveTruck(page, frame)
  183. await page.reload()
  184. frame = await readyFrame(page)
  185. const restored = await trackState(frame, id)
  186. expect(restored.targetId).toBe(targetId)
  187. expect(restored.property).toBe('position')
  188. expect(restored.muted).toBe(false)
  189. expect(restored.keyframes).toHaveLength(3)
  190. expect(restored.keyframes.find((k: any) => k.id === middleId)).toMatchObject({ time, value: [5, 0, 0], easing: 'easeInOut' })
  191. expect(await frame.evaluate(() => (window as any).editorApp.timeline.loop)).toBe(true)
  192. expect(await frame.evaluate(() => (window as any).editorApp.timeline.zoom)).toBe(0.5)
  193. authoredTrack = { id, middleId, time, targetId: targetId! }
  194. evidence('truck-timeline-curve', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
  195. 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'],
  196. scope: '真实控件和指针操作,运行时读取数值校验;curve.axis仅切换无数值独立断言,timeline.seek播放头与Alt吸附未在本例覆盖。', projectId: fixture().models.truck.id, actions: '真实 DOM 轨道/帧增删、关键帧指针拖移、播放控件、曲线输入和保存重开', restored, interpolationSamples: samples, excluded: ['未新增 solo 能力', '未将原生 GLB 动画片段当成多条编辑时间轴'] })
  197. })
  198. test('补充时间轴与蓝图工具:刻度/按键/记录/分量、基准匹配、节点搜索拖入与画布平移', async ({ page }, info) => {
  199. test.setTimeout(480_000)
  200. const token = await signIn(page)
  201. const copy = async (sourceId: string, label: string) => {
  202. const original = await call(`tran/v1/content/projects/${sourceId}`, token)
  203. expect(original.status).toBe(200)
  204. if (sourceId === '94' && !fixture().originals.bridge) {
  205. fs.writeFileSync(path.join(work, 'original-bridge.json'), JSON.stringify(original.data, null, 2))
  206. const data = fixture()
  207. data.originals.bridge = { id: sourceId, name: original.data.project.name, version: original.data.project.version,
  208. hash: crypto.createHash('sha256').update(JSON.stringify(original.data)).digest('hex') }
  209. writeFixture(data)
  210. }
  211. const result = await call(`tran/v1/content/projects/${sourceId}/copy`, token, {
  212. name: `闭环工具补充-${label}-${Date.now().toString(36)}`, version: original.data.project.version,
  213. })
  214. expect(result.status, result.message).toBe(200)
  215. const id = String(result.data.project.id)
  216. expect(['94', '156', '157']).not.toContain(id)
  217. const data = fixture()
  218. data.extraProjectIds = [...new Set([...(data.extraProjectIds || []), id])]
  219. data.blueprintToolCopies = { ...(data.blueprintToolCopies || {}), [label]: id }
  220. writeFixture(data)
  221. return id
  222. }
  223. const id = await copy('156', '卡车控件')
  224. let frame = await openEditor(page, id)
  225. const mesh = await selectMesh(frame)
  226. await activate(frame, 'timeline')
  227. await timeline(frame).locator('[data-action="stop"]').click()
  228. await timeline(frame).locator('[data-action="add-track"]').click()
  229. const trackId = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
  230. const targetOptions = await timeline(frame).locator('[data-role="track-target"] option').evaluateAll(options => options.map(o => ({ value: (o as HTMLOptionElement).value, text: o.textContent })))
  231. const targetOption = targetOptions.find(o => o.value === mesh.id) || targetOptions.find(o => o.text?.includes(mesh.name))
  232. expect(targetOption).toBeTruthy()
  233. await timeline(frame).locator('[data-role="track-target"]').selectOption(targetOption!.value)
  234. await timeline(frame).locator('[data-role="track-property"]').selectOption('position')
  235. await setRange(frame, '#timeline-root [data-role="zoom"]', '0.5')
  236. const lane = timeline(frame).locator(`.timeline-lane[data-track-id="${trackId}"]`)
  237. const laneBox = await lane.boundingBox()
  238. await lane.dblclick({ position: { x: laneBox!.width / 4, y: laneBox!.height / 2 } })
  239. const middle = (await trackState(frame, trackId)).keyframes[1]
  240. await activate(frame, 'curve')
  241. await frame.locator(`[data-curve-track="${trackId}"]`).click()
  242. await frame.locator(`[data-curve-key="${middle.id}"]`).click()
  243. await fillChanged(frame.locator('[data-curve-field="time"]'), '2')
  244. await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="0"]'), '5')
  245. await fillChanged(frame.locator('[data-curve-field="value"][data-curve-component="1"]'), '-2')
  246. await frame.locator('[data-curve-field="easing"]').selectOption('linear')
  247. const xPath = await frame.locator('.curve-path').getAttribute('points')
  248. await frame.locator('[data-curve-axis]').selectOption('1')
  249. const yPath = await frame.locator('.curve-path').getAttribute('points')
  250. expect(yPath).not.toEqual(xPath)
  251. expect((await trackState(frame, trackId)).keyframes.find((k: any) => k.id === middle.id).value).toEqual([5, -2, 0])
  252. await expect(frame.locator('.curve-label')).toContainText('-2.000')
  253. await shot(page, info, '28-curve-axis-y-negative')
  254. await frame.locator('[data-curve-axis]').selectOption('0')
  255. expect(await frame.locator('.curve-path').getAttribute('points')).toBe(xPath)
  256. await activate(frame, 'timeline')
  257. const ruler = timeline(frame).locator('[data-role="ruler"]')
  258. const rulerBox = await ruler.boundingBox()
  259. await ruler.click({ position: { x: rulerBox!.width / 8, y: rulerBox!.height / 2 } })
  260. const initialTime = await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)
  261. expect(initialTime).toBeCloseTo(1, 1)
  262. await timeline(frame).locator('[data-action="pause"]').focus()
  263. await page.keyboard.press('Shift+ArrowRight')
  264. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeCloseTo(initialTime + 1, 5)
  265. await page.keyboard.press('ArrowLeft')
  266. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBeCloseTo(initialTime + 1 - 1 / 30, 5)
  267. await page.keyboard.press('Shift+ArrowLeft')
  268. const pose = await frame.evaluate(trackId => {
  269. const e = (window as any).editorApp, track = e.timeline.getTrack(trackId), target = e.modelTargets.get(track.targetId)
  270. return { time: e.timeline.currentTime, value: e.timeline.evaluateTrack(trackId), actual: target.position.toArray(), base: target.userData.timelineBase.position }
  271. }, trackId)
  272. expect(pose.time).toBeCloseTo(initialTime - 1 / 30, 5)
  273. pose.actual.forEach((value: number, i: number) => expect(value - pose.base[i]).toBeCloseTo(pose.value[i], 5))
  274. await expect(timeline(frame).locator('[data-role="timecode"]')).not.toContainText('00:00.000 /')
  275. await frame.locator('[data-action="add-key"]').click()
  276. const recorded = (await trackState(frame, trackId)).keyframes.find((k: any) => Math.abs(k.time - pose.time) < 0.00001)
  277. expect(recorded).toBeTruthy()
  278. recorded.value.forEach((value: number, i: number) => expect(value).toBeCloseTo(pose.value[i], 5))
  279. expect((await trackState(frame, trackId)).keyframes).toHaveLength(4)
  280. await shot(page, info, '29-timeline-seek-record')
  281. const beforeNoMatch = await frame.evaluate(() => ({ timeline: (window as any).editorApp.timeline.toJSON(), blueprint: (window as any).editorApp.blueprint.toJSON() }))
  282. await frame.locator('[data-action="load-baseline-animation"]').click()
  283. await expect(frame.locator('.toast').filter({ hasText: '没有可自动匹配的基准动作' })).toBeVisible()
  284. expect(await frame.evaluate(() => ({ timeline: (window as any).editorApp.timeline.toJSON(), blueprint: (window as any).editorApp.blueprint.toJSON() }))).toEqual(beforeNoMatch)
  285. await activate(frame, 'blueprint')
  286. const bp = blueprint(frame)
  287. await bp.locator('.bp-search-input').fill('条件判断')
  288. await expect(bp.locator('.bp-palette-item')).toHaveCount(1)
  289. await expect(bp.locator('.bp-palette-item')).toContainText('条件判断')
  290. await bp.locator('.bp-search-input').fill('')
  291. await expect(bp.locator('.bp-palette-item')).toHaveCount(7)
  292. const beforeAdd = await graphState(frame)
  293. const historyIndex = await frame.evaluate(() => (window as any).editorApp.history.index)
  294. await bp.locator('[data-command="add"]').click()
  295. const added = (await graphState(frame)).nodes.filter((n: any) => !beforeAdd.nodes.some((old: any) => old.id === n.id))
  296. expect(added).toHaveLength(1)
  297. expect(added[0].type).toBe('message')
  298. expect(await frame.evaluate(() => (window as any).editorApp.history.index)).toBeGreaterThan(historyIndex)
  299. const viewport = bp.locator('.bp-viewport'), viewportBox = await viewport.boundingBox()
  300. await bp.locator('.bp-palette-item').filter({ hasText: /^◷等待/ }).dragTo(viewport, {
  301. targetPosition: { x: viewportBox!.width * 0.1, y: viewportBox!.height * 0.15 },
  302. })
  303. const dragged = (await graphState(frame)).nodes.filter((n: any) => !beforeAdd.nodes.some((old: any) => old.id === n.id) && n.id !== added[0].id)
  304. expect(dragged).toHaveLength(1)
  305. expect(dragged[0].type).toBe('wait')
  306. const graphBeforeCanvas = await frame.evaluate(() => {
  307. const b = (window as any).editorApp.blueprint
  308. return { nodes: b.nodes, edges: b.edges, viewport: b.viewport }
  309. })
  310. const blank = await viewport.evaluate(element => {
  311. const rect = element.getBoundingClientRect()
  312. for (const x of [0.9, 0.1, 0.5]) for (const y of [0.85, 0.65, 0.25]) {
  313. const hit = document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y)
  314. if (hit && !hit.closest('.bp-node,.bp-edge')) return { x: rect.width * x, y: rect.height * y }
  315. }
  316. return null
  317. })
  318. expect(blank).not.toBeNull()
  319. await bp.focus()
  320. await page.keyboard.down('Space')
  321. await page.mouse.move(viewportBox!.x + blank!.x, viewportBox!.y + blank!.y)
  322. await page.mouse.down()
  323. await page.mouse.move(viewportBox!.x + blank!.x - 45, viewportBox!.y + blank!.y - 25, { steps: 8 })
  324. await page.mouse.up()
  325. await page.keyboard.up('Space')
  326. const afterPan = await frame.evaluate(() => (window as any).editorApp.blueprint.viewport)
  327. expect(afterPan.x).toBeCloseTo(graphBeforeCanvas.viewport.x - 45, 1)
  328. expect(afterPan.y).toBeCloseTo(graphBeforeCanvas.viewport.y - 25, 1)
  329. await page.mouse.wheel(0, -120)
  330. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.viewport.zoom)).toBeGreaterThan(afterPan.zoom)
  331. await bp.locator('[data-command="frame"]').click()
  332. const graphAfterCanvas = await graphState(frame)
  333. expect(graphAfterCanvas.nodes).toEqual(graphBeforeCanvas.nodes)
  334. expect(graphAfterCanvas.edges).toEqual(graphBeforeCanvas.edges)
  335. await shot(page, info, '30-blueprint-palette-canvas')
  336. await saveTruck(page, frame, id)
  337. await page.reload(); frame = await readyFrame(page)
  338. expect((await trackState(frame, trackId)).keyframes.find((k: any) => k.id === recorded.id)).toEqual(recorded)
  339. expect((await graphState(frame)).nodes.some((n: any) => n.id === added[0].id)).toBe(true)
  340. expect((await graphState(frame)).nodes.some((n: any) => n.id === dragged[0].id)).toBe(true)
  341. const bridgeId = await copy('94', '基准匹配')
  342. frame = await openEditor(page, bridgeId)
  343. const identity = await frame.evaluate(() => ({ name: (window as any).editorApp.projectName, model: (window as any).editorApp.activeDescriptor.id }))
  344. await frame.locator('[data-action="focus"]').first().click()
  345. await activate(frame, 'timeline')
  346. // Source 94 already contains the generated baseline. Persist a real extra
  347. // track first, so reloading the baseline exercises a changed server draft
  348. // instead of correctly taking the unchanged-save fast path.
  349. await timeline(frame).locator('[data-action="add-track"]').click()
  350. const replacedTrack = await frame.evaluate(() => (window as any).editorApp.timeline.selectedTrackId)
  351. await saveTruck(page, frame, bridgeId)
  352. const savedBaseline = page.waitForResponse(response => response.request().method() === 'PUT'
  353. && new URL(response.url()).pathname.endsWith(`/content/projects/${bridgeId}`), { timeout: 120_000 })
  354. await frame.locator('[data-action="load-baseline-animation"]').click()
  355. expect((await savedBaseline).status()).toBe(200)
  356. await expect.poll(() => frame.evaluate(() => !(window as any).editorApp.apiCoverSavePending)).toBe(true)
  357. const baseline = await frame.evaluate(() => {
  358. const e = (window as any).editorApp
  359. return { name: e.projectName, timeline: e.timeline.toJSON(), resolved: e.timeline.tracks.every((t: any) => e.modelTargets.has(t.targetId)) }
  360. })
  361. expect(baseline.name).toBe(identity.name)
  362. expect(baseline.timeline.tracks.length).toBeGreaterThan(1)
  363. expect(baseline.timeline.tracks.some((t: any) => t.id === replacedTrack)).toBe(false)
  364. expect(baseline.resolved).toBe(true)
  365. await page.reload(); frame = await readyFrame(page)
  366. expect(await frame.evaluate(() => (window as any).editorApp.timeline.toJSON())).toEqual(baseline.timeline)
  367. await shot(page, info, '31-bridge-baseline-reopened')
  368. evidence('truck-blueprint-tool-supplement', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
  369. featureIds: ['timeline.seek', 'timeline.record', 'timeline.baseline', 'curve.axis', 'blueprint.palette', 'blueprint.add', 'blueprint.canvas'],
  370. projectIds: [id, bridgeId], pose, recorded, curveAxis: { xAndYPathsDiffer: xPath !== yPath, values: [5, -2, 0] },
  371. noMatchPreserved: true, baseline, addedNode: added[0], draggedNode: dragged[0], canvasPreservedGraph: true,
  372. scope: '真实模型编辑参数/基准轨道生成验收,不构成装备操作或维修指导。桥梁原工程94仅GET和copy。' })
  373. })
  374. test('蓝图真实节点库与端口拖线:七种参数、内部边复制/剪切、撤销重做及保存', async ({ page }, info) => {
  375. test.setTimeout(300_000)
  376. await signIn(page)
  377. const frame = await openEditor(page, 'truck')
  378. const mesh = await selectMesh(frame)
  379. targetAlias = mesh.id || mesh.name
  380. expect(targetAlias).toBeTruthy()
  381. await activate(frame, 'timeline')
  382. await timeline(frame).locator('[data-action="stop"]').click()
  383. await activate(frame, 'blueprint')
  384. const root = blueprint(frame)
  385. await root.focus()
  386. await page.keyboard.press('Control+a')
  387. await page.keyboard.press('Delete')
  388. expect((await graphState(frame)).nodes).toHaveLength(0)
  389. await expect(root.locator('.bp-empty-hint')).toBeVisible()
  390. const previousZoom = await frame.evaluate(() => (window as any).editorApp.blueprint.viewport.zoom)
  391. await root.locator('.bp-viewport').hover()
  392. // 80% leaves a visible gap between the three 210px nodes in this viewport.
  393. await page.mouse.wheel(0, Math.log(previousZoom / 0.8) / 0.0012)
  394. await expect.poll(() => frame.evaluate(() => Math.round((window as any).editorApp.blueprint.viewport.zoom * 1000))).toBe(800)
  395. const ids = {} as NodeIds
  396. ids.trigger = await addNode(page, frame, '点击触发', 'trigger-click', 0, 0)
  397. await (await bpField(frame, /^事件$/)).selectOption('click')
  398. await fieldText(frame, /^目标对象$/, targetAlias)
  399. ids.play = await addNode(page, frame, '播放时间轴', 'play-timeline', 1, 0)
  400. await fieldText(frame, /^(动作名称|动画片段)/, '牛奶卡车曲线联动')
  401. const duration = await frame.evaluate(() => (window as any).editorApp.timeline.duration)
  402. await fieldText(frame, /^起始时间$/, String(duration))
  403. await fieldText(frame, /^播放速度$/, '2')
  404. await (await bpField(frame, /^等待播完$/)).check()
  405. ids.wait = await addNode(page, frame, '等待', 'wait', 2, 0)
  406. await fieldText(frame, /^时长/, '100')
  407. ids.highlight = await addNode(page, frame, '高亮部件', 'highlight', 0, 1)
  408. await fieldText(frame, /^目标对象$/, targetAlias)
  409. await (await bpField(frame, /^高亮颜色$/)).evaluate(input => {
  410. (input as HTMLInputElement).value = '#ff8800'
  411. input.dispatchEvent(new Event('input', { bubbles: true }))
  412. input.dispatchEvent(new Event('change', { bubbles: true }))
  413. })
  414. await (await bpField(frame, /^呼吸效果$/)).uncheck()
  415. ids.message = await addNode(page, frame, '操作提示', 'message', 1, 1)
  416. await fieldText(frame, /^标题$/, '卡车模型闭环提示')
  417. await fieldText(frame, /^提示内容$/, '检查真实目标高亮、时间轴完成和条件出口。')
  418. await (await bpField(frame, /^级别$/)).selectOption('success')
  419. ids.condition = await addNode(page, frame, '条件判断', 'condition', 2, 1)
  420. await fieldText(frame, /^变量$/, 'timelineComplete')
  421. await (await bpField(frame, /^运算符$/)).selectOption('==')
  422. await fieldText(frame, /^比较值$/, 'true')
  423. ids.finish = await addNode(page, frame, '流程完成', 'finish', 1, 2)
  424. await (await bpField(frame, /^结果$/)).selectOption('success')
  425. await fieldText(frame, /^完成提示$/, '牛奶卡车蓝图验证完成')
  426. await root.locator('[data-command="frame"]').click()
  427. await connect(frame, ids.trigger, 'then', ids.play)
  428. await connect(frame, ids.play, 'then', ids.wait)
  429. await connect(frame, ids.wait, 'then', ids.highlight)
  430. await connect(frame, ids.highlight, 'then', ids.message)
  431. await connect(frame, ids.message, 'then', ids.condition)
  432. await connect(frame, ids.condition, 'true', ids.finish)
  433. expect((await graphState(frame)).edges).toHaveLength(6)
  434. await expect(root.locator('.bp-empty-hint')).toBeHidden()
  435. expect(await root.locator('.bp-empty-hint').evaluate(element => getComputedStyle(element).display)).toBe('none')
  436. const parameters = (await graphState(frame)).nodes
  437. const params = (id: string) => parameters.find((n: any) => n.id === id).params
  438. expect(params(ids.trigger)).toMatchObject({ event: 'click', target: targetAlias })
  439. expect(params(ids.play)).toMatchObject({ clip: '牛奶卡车曲线联动', from: duration, speed: 2, waitForEnd: true })
  440. expect(params(ids.wait)).toMatchObject({ duration: 100 })
  441. expect(params(ids.highlight)).toMatchObject({ target: targetAlias, color: '#ff8800', pulse: false })
  442. expect(params(ids.message)).toMatchObject({ title: '卡车模型闭环提示', text: '检查真实目标高亮、时间轴完成和条件出口。', level: 'success' })
  443. expect(params(ids.condition)).toMatchObject({ variable: 'timelineComplete', operator: '==', value: true })
  444. expect(params(ids.finish)).toMatchObject({ result: 'success', text: '牛奶卡车蓝图验证完成' })
  445. await shot(page, info, '23-truck-blueprint-seven-nodes')
  446. // Clipboard is driven by real shortcuts scoped to the blueprint. All seven
  447. // copied nodes must keep their six internal edges, with fresh endpoint IDs.
  448. await root.focus()
  449. await page.keyboard.press('Control+a')
  450. await page.keyboard.press('Control+c')
  451. await page.keyboard.press('Control+v')
  452. const copied = await graphState(frame)
  453. expect(copied.nodes).toHaveLength(14)
  454. expect(copied.edges).toHaveLength(12)
  455. const copyIds = copied.nodes.filter((n: any) => !Object.values(ids).includes(n.id)).map((n: any) => n.id)
  456. expect(copied.edges.filter((e: any) => copyIds.includes(e.from.node) && copyIds.includes(e.to.node))).toHaveLength(6)
  457. await page.keyboard.press('Control+x')
  458. expect((await graphState(frame)).nodes).toHaveLength(7)
  459. await page.keyboard.press('Control+v')
  460. expect((await graphState(frame)).edges).toHaveLength(12)
  461. await page.keyboard.press('Delete')
  462. expect((await graphState(frame)).nodes).toHaveLength(7)
  463. expect((await graphState(frame)).edges).toHaveLength(6)
  464. await frame.locator('[data-action="undo"]').first().click()
  465. 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 })
  466. await frame.locator('[data-action="redo"]').first().click()
  467. 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 })
  468. await root.locator('[data-command="frame"]').click()
  469. await root.locator('[data-command="run"]').click()
  470. await expect(frame.locator('[data-modal-close]')).toBeVisible()
  471. await shot(page, info, '24-truck-blueprint-real-message')
  472. await frame.locator('[data-modal-close]').click()
  473. await waitForFinished(frame)
  474. const completed = await graphState(frame)
  475. for (const id of Object.values(ids)) expect(completed.logs.some((l: any) => l.nodeId === id && l.message.startsWith('执行节点'))).toBe(true)
  476. expect(completed.logs.some((l: any) => l.message.includes('TRUE'))).toBe(true)
  477. await shot(page, info, '25-truck-blueprint-completed-log')
  478. // A long real wait allows a deterministic toolbar cancellation assertion.
  479. await selectNode(frame, ids.wait)
  480. await fieldText(frame, /^时长/, '30000')
  481. await root.locator('[data-command="run"]').click()
  482. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.blueprint.activeNodeId)).toBe(ids.wait)
  483. await root.locator('[data-command="stop"]').click()
  484. expect((await graphState(frame)).running).toBe(false)
  485. expect((await graphState(frame)).logs.some((l: any) => l.message.includes('已停止'))).toBe(true)
  486. await resetBlueprint(frame)
  487. expect(await frame.evaluate(() => (window as any).editorApp.timeline.currentTime)).toBe(0)
  488. expect(await frame.evaluate(() => (window as any).editorApp.activeHighlightMaterials.size)).toBe(0)
  489. await selectNode(frame, ids.wait)
  490. await fieldText(frame, /^时长/, '100')
  491. await saveTruck(page, frame)
  492. authoredGraph = ids
  493. evidence('truck-blueprint-ui', { status: 'PASS', kind: 'e2e+runtime-read', testTitle: info.title,
  494. 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'],
  495. scope: '节点库点选添加及实际拖动/接线/执行;不把点选当成节点拖入,不把适应画布当成鼠标平移缩放全部已验。', projectId: fixture().models.truck.id, graph: await graphState(frame), completedLog: completed.logs,
  496. actions: '真实节点库/参数控件/端口拖线/复制剪切粘贴/删除/撤销重做/运行停止重置/保存',
  497. boundaries: '200 步循环保护由独立 Node 回归覆盖;本浏览器流程不注入假图或替换执行 handler。' })
  498. })
  499. test('已保存蓝图:条件出口与目标匹配 runtime 检查,真实模型单击/双击/进入预览自动触发', async ({ page }, info) => {
  500. test.setTimeout(240_000)
  501. await signIn(page)
  502. const frame = await openEditor(page, 'truck')
  503. const ids = authoredGraph
  504. expect(ids).toBeTruthy()
  505. const restored = await graphState(frame)
  506. expect(restored.nodes.map((n: any) => n.id).sort()).toEqual(Object.values(ids).sort())
  507. expect(restored.edges).toHaveLength(6)
  508. if (authoredTrack) expect((await trackState(frame, authoredTrack.id)).keyframes.find((k: any) => k.id === authoredTrack.middleId).value[0]).toBe(5)
  509. await activate(frame, 'blueprint')
  510. const root = blueprint(frame)
  511. await root.locator('[data-command="frame"]').click()
  512. await resetBlueprint(frame)
  513. // Explicit runtime-only assertions on the graph authored with UI in test 2.
  514. // They supplement, rather than stand in for, the canvas actions below.
  515. const branches = await frame.evaluate(async ({ condition, finish }) => {
  516. const b = (window as any).editorApp.blueprint
  517. const one = async (value: boolean) => {
  518. const start = b.logs.length
  519. const result = await b.run({ startNodeId: condition, variables: { timelineComplete: value } })
  520. const logs = b.logs.slice(start)
  521. return { result, visitedFinish: logs.some((l: any) => l.nodeId === finish && l.message.startsWith('执行节点')), logs }
  522. }
  523. return { falseBranch: await one(false), trueBranch: await one(true) }
  524. }, { condition: ids.condition, finish: ids.finish })
  525. expect(branches.falseBranch.result).toBe(true)
  526. expect(branches.falseBranch.visitedFinish).toBe(false)
  527. expect(branches.trueBranch.visitedFinish).toBe(true)
  528. const mismatch = await frame.evaluate(async () => {
  529. const b = (window as any).editorApp.blueprint, count = b.logs.length
  530. const result = await b.trigger('click', { object: '__different_real_target__' })
  531. return { result, started: b.logs.slice(count).some((l: any) => l.message.startsWith('开始执行')) }
  532. })
  533. expect(mismatch.started).toBe(false)
  534. await shot(page, info, '26-truck-condition-branches-runtime')
  535. // Bind to the actual model root alias so any visible child pixel can trigger
  536. // it. The pointer locations are found by read-only raycasting; the real
  537. // renderer's pointer handlers produce the payload (no fake trigger dispatch).
  538. const rootAlias = await frame.evaluate(() => {
  539. const model = (window as any).editorApp.modelPivot
  540. return model.userData.editorId || model.name
  541. })
  542. expect(rootAlias).toBeTruthy()
  543. await selectNode(frame, ids.trigger)
  544. await fieldText(frame, /^目标对象$/, rootAlias)
  545. const events: unknown[] = []
  546. for (const event of ['click', 'double-click', 'auto']) {
  547. await selectNode(frame, ids.trigger)
  548. await (await bpField(frame, /^事件$/)).selectOption(event)
  549. await resetBlueprint(frame)
  550. await frame.locator('[data-action="preview"]').first().click()
  551. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(true)
  552. if (event !== 'auto') {
  553. const point = await frame.evaluate(() => {
  554. const e = (window as any).editorApp
  555. const rect = e.renderer.domElement.getBoundingClientRect()
  556. const ray = new e.raycaster.constructor()
  557. const pointer = e.pointer.clone()
  558. for (let iy = 2; iy < 19; iy++) for (let ix = 2; ix < 19; ix++) {
  559. const x = ix / 20, y = iy / 20
  560. if (document.elementFromPoint(rect.left + rect.width * x, rect.top + rect.height * y) !== e.renderer.domElement) continue
  561. pointer.set(x * 2 - 1, -(y * 2 - 1))
  562. ray.setFromCamera(pointer, e.camera)
  563. const hit = ray.intersectObjects([e.modelPivot], true).find((hit: any) => {
  564. for (let o = hit.object; o; o = o.parent) if (!o.visible) return false
  565. return true
  566. })
  567. if (hit) return { x: x * rect.width, y: y * rect.height, mesh: hit.object.name }
  568. }
  569. return null
  570. })
  571. expect(point, 'A visible, unobstructed real truck mesh pixel must be raycastable').not.toBeNull()
  572. const canvas = frame.locator('#render-host canvas')
  573. if (event === 'click') await canvas.click({ position: point! })
  574. else await canvas.dblclick({ position: point!, delay: 70 })
  575. }
  576. await expect(frame.locator('[data-modal-close]')).toBeVisible()
  577. await frame.locator('[data-modal-close]').click()
  578. await waitForFinished(frame)
  579. const state = await graphState(frame)
  580. expect(state.logs.filter((l: any) => l.message.startsWith('开始执行'))).toHaveLength(1)
  581. expect(state.logs.some((l: any) => l.nodeId === ids.finish && l.message.startsWith('执行节点'))).toBe(true)
  582. events.push({ event, starts: 1, finished: true, source: event === 'auto' ? '真实点击进入预览' : '真实 renderer canvas 指针事件', logs: state.logs })
  583. await shot(page, info, `27-truck-preview-${event}`)
  584. await frame.locator('.preview-exit[data-action="preview"]').click()
  585. await expect.poll(() => frame.evaluate(() => (window as any).editorApp.preview)).toBe(false)
  586. }
  587. // Keep a normal click trigger in the saved copy; no test-only handler/data is
  588. // stored. Reopen verifies graph parameters survived the actual backend save.
  589. await selectNode(frame, ids.trigger)
  590. await (await bpField(frame, /^事件$/)).selectOption('click')
  591. await resetBlueprint(frame)
  592. await saveTruck(page, frame)
  593. await page.reload()
  594. const reopened = await readyFrame(page)
  595. const finalGraph = await graphState(reopened)
  596. expect(finalGraph.nodes.find((n: any) => n.id === ids.trigger).params).toMatchObject({ event: 'click', target: rootAlias })
  597. expect(finalGraph.edges).toHaveLength(6)
  598. evidence('truck-blueprint-events', { status: 'PASS', kind: 'e2e', testTitle: info.title,
  599. featureIds: ['blueprint.click', 'blueprint.double-click', 'blueprint.auto', 'view.preview'], projectId: fixture().models.truck.id,
  600. runtimeSupplement: { status: 'PASS', kind: 'runtime', featureIds: ['blueprint.condition'], branches, mismatch }, browserEvents: events,
  601. persistedNodeCount: finalGraph.nodes.length, persistedEdgeCount: finalGraph.edges.length,
  602. scope: '执行使用正式模型编辑器的真实模型/时间轴/高亮/提示 handler;不等同于学员端任务下发。' })
  603. })