Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 

277 linhas
13 KiB

  1. import type { Page } from '@playwright/test'
  2. import { captureScreenshot, expect, test } from './fixtures'
  3. import { adminCredentials, confirmMessageBox, loginAsAdmin } from './helpers'
  4. const missingProjectId = '9223372036854775807'
  5. const contentPages = [
  6. { path: '/content/models', childPath: `/content/models/${missingProjectId}/edit`, label: '模型制作' },
  7. { path: '/content/scenes', childPath: `/content/scenes/${missingProjectId}/edit`, label: '场景制作' },
  8. { path: '/content/training-projects', childPath: `/content/training-projects/${missingProjectId}/edit`, label: '训练编排' },
  9. { path: '/content/ofd', childPath: `/content/ofd/${missingProjectId}/preview`, label: '作业指导' },
  10. ] as const
  11. const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  12. const menuRow = (page: Page, name: string) => page
  13. .locator('.menu-table-panel .el-table__body-wrapper .el-table__row')
  14. .filter({
  15. has: page.locator('.menu-name-cell b').filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }),
  16. })
  17. .first()
  18. const waitForMenuManagement = async (page: Page) => {
  19. await expect(page.locator('.menus-page')).toBeVisible()
  20. await expect(page.locator('.menu-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 })
  21. await expect(page.locator('.menu-table-panel .el-table__body-wrapper .el-table__row').first()).toBeVisible({ timeout: 15_000 })
  22. }
  23. const expectActiveContentMenu = async (page: Page, label: string, href: string) => {
  24. await expect(page.locator('.header-navigation .header-nav-trigger.active')).toContainText('内容制作')
  25. const activeItem = page.locator('.secondary-navigation a.active')
  26. await expect(activeItem).toContainText(label)
  27. await expect(activeItem).toHaveAttribute('href', href)
  28. }
  29. const waitForContentList = async (page: Page) => {
  30. await expect(page.locator('.content-projects-page .content-table-panel')).toBeVisible()
  31. await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 })
  32. }
  33. const positionText = (row: ReturnType<typeof menuRow>) => row
  34. .locator('.cell')
  35. .filter({ hasText: /^\d+\s*\/\s*\d+$/ })
  36. .first()
  37. test('菜单草稿操作不落库且新版内容导航与子路由活动态正确', async ({ page }, testInfo) => {
  38. await loginAsAdmin(page)
  39. await test.step('管理员进入菜单管理并验证搜索与重置', async () => {
  40. await page.goto('/system/menus')
  41. await expect(page).toHaveURL(/\/system\/menus(?:\?|$)/)
  42. // 页头卡已去掉,页面身份改由页面标签栏承担
  43. await expect(page.getByRole('tab', { name: '菜单管理', exact: true })).toBeVisible()
  44. await waitForMenuManagement(page)
  45. await expect(page.locator('.secondary-navigation a.active')).toContainText('菜单管理')
  46. const toolbar = page.locator('.menu-toolbar')
  47. const query = toolbar.getByPlaceholder('请输入菜单名称、权限码或说明')
  48. await query.fill('模型制作')
  49. await toolbar.getByRole('button', { name: '搜索', exact: true }).click()
  50. await expect(menuRow(page, '模型制作')).toBeVisible()
  51. await expect(page.locator('.sorting-note')).toBeVisible()
  52. await captureScreenshot(page, testInfo, 'menu-search-model')
  53. await toolbar.getByRole('button', { name: '重置', exact: true }).click()
  54. await expect(query).toHaveValue('')
  55. await expect(page.locator('.sorting-note')).toHaveCount(0)
  56. await expect(menuRow(page, '场景制作')).toBeVisible()
  57. })
  58. const menuMutationRequests: string[] = []
  59. page.on('request', (request) => {
  60. if (/\/menus(?:\/|\?|$)/.test(request.url()) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
  61. menuMutationRequests.push(`${request.method()} ${request.url()}`)
  62. }
  63. })
  64. await test.step('三种菜单布局预览均可切换', async () => {
  65. await page.getByRole('button', { name: '布局预览', exact: true }).click()
  66. const drawer = page.locator('.menu-preview-drawer:visible, .el-drawer:visible').last()
  67. const preview = drawer.locator('.menu-layout-preview')
  68. await expect(drawer).toBeVisible()
  69. const modes = [
  70. { label: '顶部一级 + 左侧二级', value: 'top-side', screenshot: 'menu-preview-top-side' },
  71. { label: '左侧分组', value: 'side', screenshot: 'menu-preview-side' },
  72. { label: '顶部下拉', value: 'top', screenshot: 'menu-preview-top' },
  73. ] as const
  74. for (const mode of modes) {
  75. await drawer.locator('.el-radio-button').filter({ hasText: mode.label }).click()
  76. await expect(preview).toHaveAttribute('data-mode', mode.value)
  77. if (mode.value === 'top-side') {
  78. await expect(preview.locator(':scope > header')).toBeVisible()
  79. await expect(preview.locator(':scope > aside')).toBeVisible()
  80. await expect(preview.locator('.preview-dropdown')).toHaveCount(0)
  81. } else if (mode.value === 'side') {
  82. await expect(preview.locator(':scope > header')).toHaveCount(0)
  83. await expect(preview.locator(':scope > aside')).toBeVisible()
  84. await expect(preview.locator('.preview-tree-group').first()).toBeVisible()
  85. } else {
  86. await expect(preview.locator(':scope > header')).toBeVisible()
  87. await expect(preview.locator(':scope > aside')).toHaveCount(0)
  88. await expect(preview.locator('.preview-dropdown')).toBeVisible()
  89. }
  90. await captureScreenshot(page, testInfo, mode.screenshot)
  91. }
  92. await page.keyboard.press('Escape')
  93. await expect(drawer).toBeHidden()
  94. })
  95. await test.step('编辑显示名称只形成草稿并可完整撤销', async () => {
  96. const originalName = '模型制作'
  97. const temporaryName = '模型制作临时草稿'
  98. const row = menuRow(page, originalName)
  99. await row.getByRole('button', { name: '编辑', exact: true }).click()
  100. const drawer = page.locator('.el-drawer:visible').last()
  101. await expect(drawer).toContainText('编辑菜单显示信息')
  102. await drawer.getByPlaceholder('请输入 2–16 个字符的菜单名称').fill(temporaryName)
  103. await drawer.getByRole('button', { name: '应用到草稿', exact: true }).click()
  104. await expect(drawer).toBeHidden()
  105. await expect(menuRow(page, temporaryName)).toBeVisible()
  106. await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled()
  107. await captureScreenshot(page, testInfo, 'menu-edit-draft')
  108. await page.getByRole('button', { name: '撤销草稿', exact: true }).click()
  109. await confirmMessageBox(page, '撤销修改')
  110. await expect(menuRow(page, originalName)).toBeVisible()
  111. await expect(menuRow(page, temporaryName)).toHaveCount(0)
  112. await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled()
  113. })
  114. await test.step('同级排序只形成草稿并可完整撤销', async () => {
  115. const row = menuRow(page, '模型制作')
  116. const originalPosition = (await positionText(row).innerText()).trim()
  117. const match = originalPosition.match(/^(\d+)\s*\/\s*(\d+)$/)
  118. expect(match, `无法识别菜单当前位置:${originalPosition}`).not.toBeNull()
  119. const current = Number(match?.[1])
  120. const total = Number(match?.[2])
  121. expect(total).toBeGreaterThan(1)
  122. const moveLabel = current < total ? '下移' : '上移'
  123. const expectedPosition = `${current < total ? current + 1 : current - 1} / ${total}`
  124. await row.getByRole('button', { name: moveLabel, exact: true }).click()
  125. await expect(positionText(menuRow(page, '模型制作'))).toHaveText(expectedPosition)
  126. await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled()
  127. await captureScreenshot(page, testInfo, 'menu-reorder-draft')
  128. await page.getByRole('button', { name: '撤销草稿', exact: true }).click()
  129. await confirmMessageBox(page, '撤销修改')
  130. await expect(positionText(menuRow(page, '模型制作'))).toHaveText(originalPosition)
  131. await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled()
  132. })
  133. await test.step('四类内容新名称、训练模式与列表活动态正确', async () => {
  134. for (const item of contentPages) {
  135. await page.goto(item.path)
  136. await expect(page).toHaveURL(new RegExp(`${item.path.replaceAll('/', '\\/')}(?:\\?|$)`))
  137. // 内容制作页面已去掉 SystemPageHeader,页面身份改由页面标签栏承担
  138. await expect(page.getByRole('tab', { name: item.label, exact: true })).toBeVisible()
  139. await waitForContentList(page)
  140. await expectActiveContentMenu(page, item.label, item.path)
  141. if (item.path === '/content/training-projects') {
  142. const modeTabs = page.locator('.training-mode-tabs button')
  143. await expect(modeTabs).toHaveCount(3)
  144. await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toBeVisible()
  145. await expect(modeTabs.filter({ hasText: '实装实训' })).toBeVisible()
  146. await expect(modeTabs.filter({ hasText: '对抗训练' })).toBeVisible()
  147. await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toHaveAttribute('aria-pressed', 'true')
  148. await captureScreenshot(page, testInfo, 'content-training-three-modes')
  149. }
  150. if (item.path === '/content/ofd') {
  151. await expect(page.locator('.content-table-panel')).toBeVisible()
  152. await captureScreenshot(page, testInfo, 'content-guide-list-or-empty')
  153. }
  154. }
  155. })
  156. await test.step('内容编辑、预览子路由保持对应菜单活动态', async () => {
  157. for (const item of contentPages) {
  158. await page.goto(item.childPath)
  159. await expect(page).toHaveURL(new RegExp(`${item.childPath.replaceAll('/', '\\/')}(?:\\?|$)`))
  160. await expect(page.locator('.content-editor-page, .guide-workflow-page')).toBeVisible()
  161. await expectActiveContentMenu(page, item.label, item.path)
  162. }
  163. await captureScreenshot(page, testInfo, 'content-child-route-active')
  164. })
  165. expect(menuMutationRequests, '草稿编辑、排序、预览与撤销不应调用菜单写接口').toEqual([])
  166. })
  167. interface ApiMenuNode {
  168. id: string
  169. name: string
  170. description: string
  171. dataVersion: number
  172. children: ApiMenuNode[]
  173. }
  174. const configurationPayload = (desired: ApiMenuNode[], versionSource: ApiMenuNode[]) => {
  175. const versions = new Map(versionSource.flatMap((group) => [group, ...group.children]).map((node) => [String(node.id), node.dataVersion]))
  176. return {
  177. groups: desired.map((group) => ({
  178. id: String(group.id),
  179. name: group.name,
  180. description: group.description,
  181. version: versions.get(String(group.id)),
  182. items: group.children.map((item) => ({
  183. id: String(item.id),
  184. name: item.name,
  185. description: item.description,
  186. version: versions.get(String(item.id)),
  187. })),
  188. })),
  189. }
  190. }
  191. test('菜单配置可原子保存并恢复原始快照', async ({ request }) => {
  192. const credentials = adminCredentials()
  193. const loginResponse = await request.post('/api/auth/v1/auth/login', {
  194. data: { username: credentials.username, password: credentials.password, roleCode: 'admin', rememberMe: false },
  195. })
  196. expect(loginResponse.ok()).toBeTruthy()
  197. const loginBody = await loginResponse.json() as { data?: { accessToken?: string } }
  198. const accessToken = loginBody.data?.accessToken
  199. expect(accessToken).toBeTruthy()
  200. const headers = { Authorization: `Bearer ${accessToken}` }
  201. const readTree = async () => {
  202. const response = await request.get('/api/auth/v1/menus/tree', { headers })
  203. expect(response.ok()).toBeTruthy()
  204. const body = await response.json() as { data?: ApiMenuNode[] }
  205. return body.data ?? []
  206. }
  207. try {
  208. const baseline = await readTree()
  209. expect(baseline).toHaveLength(5)
  210. const draft = structuredClone(baseline)
  211. const contentGroup = draft.find((group) => group.children.some((item) => item.name === '模型制作'))
  212. const modelItem = contentGroup?.children.find((item) => item.name === '模型制作')
  213. expect(modelItem).toBeTruthy()
  214. modelItem!.name = '模型制作验收'
  215. let changed = false
  216. try {
  217. const saveResponse = await request.put('/api/auth/v1/menus/configuration', {
  218. headers,
  219. data: configurationPayload(draft, baseline),
  220. })
  221. expect(saveResponse.ok()).toBeTruthy()
  222. changed = true
  223. const saved = await readTree()
  224. expect(saved.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeTruthy()
  225. } finally {
  226. if (changed) {
  227. const current = await readTree()
  228. const restoreResponse = await request.put('/api/auth/v1/menus/configuration', {
  229. headers,
  230. data: configurationPayload(baseline, current),
  231. })
  232. expect(restoreResponse.ok()).toBeTruthy()
  233. const restored = await readTree()
  234. expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作')).toBeTruthy()
  235. expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeFalsy()
  236. }
  237. }
  238. } finally {
  239. await request.post('/api/auth/v1/auth/logout', { headers })
  240. }
  241. })