Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 

368 řádky
20 KiB

  1. import { expect, test, type Page, type Route } from '@playwright/test'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. /**
  5. * 作业指导书全流程回归:编制 → 送审 → 审批时间线 → 预览 → 预检 → 批准 → 发布 → 交付物下载。
  6. *
  7. * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 teaching-module 系列同一做法),
  8. * 验证前端流程、生命周期流转请求、预检口径与浏览器内交付物生成,不依赖数据库与在线服务。
  9. * 打桩的状态流转表与服务端 REVIEW_TRANSITIONS 一致,契约分叉会在这里暴露。
  10. */
  11. type Json = Record<string, unknown>
  12. const SHOTS = path.resolve('artifacts/content-guide-lifecycle')
  13. fs.mkdirSync(SHOTS, { recursive: true })
  14. const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-ofd' })
  15. const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
  16. const fail = (route: Route, status: number, message: string) => route.fulfill({
  17. status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-ofd' }),
  18. })
  19. const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
  20. const GUIDE_IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZSiLoAAAAGUlEQVR4nGP8z8DAwMDAxMDAwMDAAAALAAH+Q0KzAAAAAElFTkSuQmCC', 'base64')
  21. const step = (id: string, title: string): Json => ({
  22. id, title, order: 1,
  23. summary: '确认整机停机并系统卸压后,按对角顺序拆除缸筒固定螺栓。',
  24. safety: '全程佩戴护目镜与防割手套,作业区设置警戒。',
  25. tools: '液压扳手、专用吊具',
  26. acceptance: '缸筒外观无划痕,接口无渗漏。',
  27. part: '支腿液压缸', anchor: '右前支腿默认视角', animation: '支腿拆卸', media: 'GUIDE_IMAGE_01',
  28. stepType: '拆卸', duration: '12:30', status: '已完成', hotspot: true, start: 0, end: 12,
  29. parameters: { 拧紧力矩: '220 N·m', 系统压力: '不高于 0.5 MPa', 环境温度: '5 至 35 摄氏度' },
  30. })
  31. const completeContent = (): Json => ({
  32. equipment: 'GX-200 全地形起重机',
  33. owner: '整机维修保障中心',
  34. specification: 'GB/T 33190-2016',
  35. chapters: [
  36. { id: 'c1', number: '01', title: '作业准备', order: 1, steps: [step('s1', '停机挂牌与卸压')] },
  37. { id: 'c2', number: '02', title: '缸体拆卸', order: 2, steps: [step('s2', '拆卸支腿液压缸')] },
  38. ],
  39. resources: [{ id: 'r1', assetCode: 'GUIDE_IMAGE_01', type: '图片', name: '支腿总成配图', status: 'READY' }],
  40. partsCatalog: [{ id: 'p1', name: '支腿液压缸' }],
  41. three: { parts: ['支腿液压缸'], markers: [], selectedPart: '支腿液压缸' },
  42. publish: { version: 'V1.2.0', note: '首次正式发布', channels: ['OFD静态版式', '单机离线包'], publishedAt: '' },
  43. })
  44. const TRANSITIONS: Record<string, { from: string[]; to: string; commentRequired: boolean }> = {
  45. SUBMIT: { from: ['DRAFT', 'REJECTED'], to: 'REVIEW', commentRequired: false },
  46. WITHDRAW: { from: ['REVIEW'], to: 'DRAFT', commentRequired: false },
  47. APPROVE: { from: ['REVIEW'], to: 'APPROVED', commentRequired: false },
  48. REJECT: { from: ['REVIEW'], to: 'REJECTED', commentRequired: true },
  49. REVOKE: { from: ['PUBLISHED'], to: 'WITHDRAWN', commentRequired: true },
  50. REVISE: { from: ['REJECTED', 'WITHDRAWN', 'SUPERSEDED'], to: 'DRAFT', commentRequired: false },
  51. }
  52. class GuideMock {
  53. readonly requests: Array<{ method: string; path: string; body: Json }> = []
  54. content: Json = completeContent()
  55. status = 'DRAFT'
  56. version = 3
  57. publishedVersionId: string | null = null
  58. readCount = 0
  59. downloadCount = 0
  60. reviews: Json[] = []
  61. private assets() {
  62. return [{
  63. id: 'asset-image-01', versionId: '11', code: 'GUIDE_IMAGE_01', name: '支腿总成配图',
  64. type: 'IMAGE', storageUri: 'content://sha256/guide-image-01', mimeType: 'image/png',
  65. sizeBytes: GUIDE_IMAGE.length, sha256: 'a'.repeat(64), metadata: { format: 'PNG' }, status: 'READY', sortOrder: 0,
  66. }]
  67. }
  68. private project() {
  69. return {
  70. id: '901', type: 'GUIDE', code: 'OFD-2026-0031', name: '支腿液压缸中修作业指导书',
  71. description: '整机支腿液压缸中修标准作业', categoryCode: 'MAINTENANCE', coverUri: '',
  72. status: this.status, version: this.version,
  73. currentVersionId: '11', currentVersionNo: 3, currentVersionCode: 'V1.2.0', currentVersionStatus: 'DRAFT',
  74. publishedVersionId: this.publishedVersionId,
  75. ownerUserId: '7', ownerName: '内容作者', departmentId: '20', departmentName: '维修保障中心',
  76. addTime: 1786387200, updateTime: 1786387200, publishedTime: this.publishedVersionId ? 1786387200 : null,
  77. usageCount: 0, readCount: this.readCount, downloadCount: this.downloadCount,
  78. dependencyCount: 0, assetCount: this.assets().length,
  79. }
  80. }
  81. private version11(status = 'DRAFT') {
  82. return {
  83. id: '11', projectId: '901', versionNo: 3, versionCode: 'V1.2.0', status,
  84. content: this.content, dependencies: [], assets: this.assets(),
  85. createdByName: '内容作者', publishedByName: status === 'PUBLISHED' ? '内容作者' : '',
  86. publishedTime: status === 'PUBLISHED' ? 1786387200 : null,
  87. addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
  88. }
  89. }
  90. private detail() {
  91. return { project: this.project(), currentVersion: this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT') }
  92. }
  93. async install(page: Page) {
  94. await page.addInitScript(() => {
  95. sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-ofd-token')
  96. })
  97. await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
  98. user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
  99. activeRoleId: 'role-admin',
  100. roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }],
  101. permissions: ['content.ofd', 'content.model', 'content.scene', 'content.training', 'content.create', 'content.update', 'content.publish', 'content.delete'],
  102. authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
  103. }))
  104. await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
  105. await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
  106. }
  107. private async handle(route: Route) {
  108. const request = route.request()
  109. const url = new URL(request.url())
  110. const target = url.pathname.replace(/^.*\/v1\/content/, '')
  111. const method = request.method()
  112. let body: Json = {}
  113. try {
  114. const text = request.postData()
  115. if (text) body = JSON.parse(text) as Json
  116. } catch { body = {} }
  117. this.requests.push({ method, path: target, body })
  118. if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
  119. if (method === 'GET' && target === '/projects/summary') {
  120. return ok(route, { total: 1, draft: 0, review: 0, published: 0, byType: [], byTrainingMode: [] })
  121. }
  122. if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
  123. if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
  124. if (method === 'PUT' && target === '/projects/901') {
  125. if (body.content && typeof body.content === 'object') this.content = body.content as Json
  126. this.version += 1
  127. return ok(route, this.detail())
  128. }
  129. if (method === 'GET' && target === '/projects/901/versions') {
  130. return ok(route, pageOf([this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')]))
  131. }
  132. if (method === 'GET' && target === '/projects/901/versions/11') {
  133. return ok(route, this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT'))
  134. }
  135. if (method === 'GET' && target === '/projects/901/assets/asset-image-01/content') {
  136. return route.fulfill({ status: 200, contentType: 'image/png', body: GUIDE_IMAGE })
  137. }
  138. if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [...this.reviews].reverse())
  139. if (method === 'POST' && target === '/projects/901/review') {
  140. const action = String(body.action ?? '')
  141. const transition = TRANSITIONS[action]
  142. if (!transition) return fail(route, 400, '动作无效')
  143. if (!transition.from.includes(this.status)) return fail(route, 409, '起始状态不匹配')
  144. const comment = String(body.comment ?? '').trim()
  145. if (transition.commentRequired && !comment) return fail(route, 400, '该动作必须填写处理意见')
  146. this.reviews.push({
  147. id: String(this.reviews.length + 1), projectId: '901', versionId: '11', action,
  148. fromStatus: this.status, toStatus: transition.to, comment,
  149. actorUserId: '1', actorName: '管理员', operateTime: 1786387200 + this.reviews.length * 60,
  150. })
  151. this.status = transition.to
  152. this.version += 1
  153. return ok(route, this.detail())
  154. }
  155. if (method === 'POST' && target === '/projects/901/publish') {
  156. if (!['REVIEW', 'APPROVED'].includes(this.status)) return fail(route, 409, '指导书必须先提交复核,复核中或已批准才能发布')
  157. this.status = 'PUBLISHED'
  158. this.publishedVersionId = '11'
  159. this.version += 1
  160. return ok(route, this.detail())
  161. }
  162. if (method === 'POST' && target === '/projects/901/metrics') {
  163. if (String(body.metric) === 'READ') this.readCount += 1
  164. if (String(body.metric) === 'DOWNLOAD') this.downloadCount += 1
  165. return ok(route, {})
  166. }
  167. return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
  168. }
  169. }
  170. const statValue = async (page: Page, label: string) => (
  171. page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText()
  172. )
  173. test.describe('作业指导书生命周期与交付物', () => {
  174. test('编制 → 预览 → 预检 → 送审 → 批准 → 发布 → 交付物下载', async ({ page }) => {
  175. const mock = new GuideMock()
  176. await mock.install(page)
  177. const consoleErrors: string[] = []
  178. page.on('pageerror', (error) => consoleErrors.push(error.message))
  179. // 0. 资源导入:只能展示真实版本资产,并提供受控图片上传入口
  180. await page.goto('/content/ofd/901/studio?view=resources')
  181. await expect(page.locator('button', { hasText: '上传配图' })).toBeVisible()
  182. const resourceTable = page.locator('.single-panel .el-table').first()
  183. await expect(resourceTable).toContainText('GUIDE_IMAGE_01')
  184. await expect(resourceTable).toContainText('已就绪')
  185. // 1. 编制:工作台读到草稿,能改内容并保存
  186. await page.goto('/content/ofd/901/studio?view=editor')
  187. await expect(page.locator('.guide-flow-panel')).toBeVisible()
  188. await expect(page.locator('.chapter-panel')).toContainText('作业准备')
  189. await page.locator('.step-row', { hasText: '停机挂牌与卸压' }).first().click()
  190. const titleInput = page.locator('.step-editor .form-grid input').first()
  191. await titleInput.fill('停机挂牌与系统卸压')
  192. // el-input 的 change 在失焦时触发,脏标记要等失焦后才置位。
  193. await titleInput.blur()
  194. await expect(page.locator('.studio-dirty')).toBeVisible()
  195. await page.getByRole('button', { name: /^保存$/ }).click()
  196. await expect(page.locator('.studio-dirty')).toHaveCount(0)
  197. const saved = mock.requests.filter((item) => item.method === 'PUT')
  198. expect(saved.length).toBeGreaterThan(0)
  199. await page.screenshot({ path: path.join(SHOTS, '01-编制.png'), fullPage: true })
  200. // 2. 送审:再次修改但不手工保存,生命周期动作必须先保存最新草稿再发起 SUBMIT
  201. await titleInput.fill('停机挂牌、系统卸压与复核确认')
  202. await titleInput.blur()
  203. await expect(page.locator('.studio-dirty')).toBeVisible()
  204. const putsBeforeSubmit = mock.requests.filter((item) => item.method === 'PUT').length
  205. await page.getByRole('button', { name: '提交复核' }).click()
  206. await page.getByRole('button', { name: '确认提交复核' }).click()
  207. await expect.poll(() => mock.status).toBe('REVIEW')
  208. expect(mock.requests.filter((item) => item.method === 'PUT').length).toBe(putsBeforeSubmit + 1)
  209. expect(((mock.content.chapters as Json[])[0]!.steps as Json[])[0]!.title).toBe('停机挂牌、系统卸压与复核确认')
  210. await page.screenshot({ path: path.join(SHOTS, '02-送审.png'), fullPage: true })
  211. // 3. 审批时间线读到刚才的流转
  212. await page.goto('/content/ofd/901/studio?view=feedback')
  213. await expect(page.locator('.single-panel')).toContainText('提交复核')
  214. await expect(page.locator('.single-panel')).toContainText('草稿 → 审核中')
  215. await page.screenshot({ path: path.join(SHOTS, '03-审批时间线.png'), fullPage: true })
  216. // 4. 预览:章节目录与正文来自当前草稿
  217. await page.goto('/content/ofd/901/preview')
  218. await expect(page.locator('.guide-toc')).toContainText('缸体拆卸')
  219. await expect(page.locator('.guide-document')).toContainText('停机挂牌、系统卸压与复核确认')
  220. await page.screenshot({ path: path.join(SHOTS, '04-预览.png'), fullPage: true })
  221. // 5. 预检:24 项内容检查 + 3 项平台检查全部通过
  222. await page.goto('/content/ofd/901/preflight')
  223. await expect(page.locator('.preflight-summary')).toBeVisible()
  224. expect(await statValue(page, '通过')).toBe('26')
  225. expect(await statValue(page, '待确认')).toBe('1')
  226. expect(await statValue(page, '阻断')).toBe('0')
  227. expect(await statValue(page, '完成度')).toContain('100')
  228. const enterPublish = page.getByRole('button', { name: '进入发布确认' })
  229. await expect(enterPublish).toBeEnabled()
  230. await page.screenshot({ path: path.join(SHOTS, '05-预检.png'), fullPage: true })
  231. // 6. 批准 + 发布
  232. await enterPublish.click()
  233. await expect(page).toHaveURL(/\/content\/ofd\/901\/publish$/)
  234. await page.getByRole('button', { name: '复核通过' }).click()
  235. await page.getByRole('button', { name: '确认复核通过' }).click()
  236. await expect.poll(() => mock.status).toBe('APPROVED')
  237. await page.getByRole('button', { name: '确认发布' }).click()
  238. await page.getByRole('button', { name: '确认发布' }).last().click()
  239. await expect.poll(() => mock.status).toBe('PUBLISHED')
  240. await expect(page.locator('.publish-confirmation h2')).toContainText('当前版本已发布')
  241. await page.screenshot({ path: path.join(SHOTS, '06-发布.png'), fullPage: true })
  242. // 7. 交付物:OFD 静态版式与单机离线包都能生成、自校验通过并触发下载
  243. const ofdDownload = page.waitForEvent('download')
  244. await page.getByRole('button', { name: 'OFD 静态版式' }).click()
  245. const ofdFile = await ofdDownload
  246. expect(ofdFile.suggestedFilename()).toMatch(/\.ofd$/)
  247. const ofdPath = path.join(SHOTS, 'delivery.ofd')
  248. await ofdFile.saveAs(ofdPath)
  249. const ofdBytes = fs.readFileSync(ofdPath)
  250. expect(ofdBytes.length).toBeGreaterThan(4096)
  251. expect(ofdBytes.subarray(0, 4).toString('hex')).toBe('504b0304')
  252. expect(ofdBytes.toString('latin1')).toContain('META-INF/EXPORT-METADATA.json')
  253. await expect(page.locator('.delivery-report')).toContainText('OFD 静态版式')
  254. await expect(page.locator('.delivery-report')).toContainText('发布版本 V1.2.0')
  255. const hash = await page.locator('.delivery-hash dd').innerText()
  256. expect(hash).toMatch(/^[a-f0-9]{64}$/)
  257. // 交付信息必须单列堆叠,否则文件名与 64 位哈希会被挤成折行。
  258. const rows = page.locator('.delivery-report > div')
  259. const firstRow = await rows.nth(0).boundingBox()
  260. const secondRow = await rows.nth(1).boundingBox()
  261. expect(secondRow!.y).toBeGreaterThanOrEqual(firstRow!.y + firstRow!.height - 1)
  262. const offlineDownload = page.waitForEvent('download')
  263. await page.getByRole('button', { name: '单机离线包' }).click()
  264. const offlineFile = await offlineDownload
  265. expect(offlineFile.suggestedFilename()).toMatch(/离线阅读版\.html$/)
  266. const offlinePath = path.join(SHOTS, 'delivery-offline.html')
  267. await offlineFile.saveAs(offlinePath)
  268. const offlineHtml = fs.readFileSync(offlinePath, 'utf8')
  269. expect(offlineHtml.startsWith('<!doctype html>')).toBe(true)
  270. expect(offlineHtml).toContain('Content-Security-Policy')
  271. expect(offlineHtml).toContain('data-role="toc"')
  272. expect(offlineHtml.match(/data-step-page="\d+"/g)?.length).toBe(2)
  273. expect(offlineHtml.match(/data:image\/png;base64/g)?.length).toBe(2)
  274. expect(/<(?:script[^>]*src|link[^>]*href|img[^>]*src)=["']https?:/i.test(offlineHtml)).toBe(false)
  275. await page.screenshot({ path: path.join(SHOTS, '07-交付物.png'), fullPage: true })
  276. // 8. 两次下载各回报一次 DOWNLOAD 统计
  277. expect(mock.downloadCount).toBe(2)
  278. const metrics = mock.requests.filter((item) => item.path === '/projects/901/metrics')
  279. expect(metrics.map((item) => item.body.metric)).toEqual(['DOWNLOAD', 'DOWNLOAD'])
  280. expect(consoleErrors, `页面运行时错误:${consoleErrors.join(' | ')}`).toEqual([])
  281. })
  282. test('撤回发布必须填写处理意见', async ({ page }) => {
  283. const mock = new GuideMock()
  284. mock.status = 'PUBLISHED'
  285. mock.publishedVersionId = '11'
  286. await mock.install(page)
  287. await page.goto('/content/ofd/901/studio?view=editor')
  288. await page.getByRole('button', { name: '撤回发布' }).click()
  289. const dialog = page.locator('.el-dialog')
  290. await expect(dialog).toBeVisible()
  291. await dialog.getByRole('button', { name: '确认撤回发布' }).click()
  292. await expect(page.locator('.el-message')).toContainText('必须填写处理意见')
  293. expect(mock.status).toBe('PUBLISHED')
  294. await dialog.locator('textarea').fill('发现第 2 步拧紧力矩参数有误')
  295. await dialog.getByRole('button', { name: '确认撤回发布' }).click()
  296. await expect.poll(() => mock.status).toBe('WITHDRAWN')
  297. const record = mock.reviews.at(-1)
  298. expect(record?.action).toBe('REVOKE')
  299. expect(record?.comment).toBe('发现第 2 步拧紧力矩参数有误')
  300. await page.screenshot({ path: path.join(SHOTS, '08-撤回发布.png'), fullPage: true })
  301. })
  302. test('要素缺失时预检阻断并禁用发布确认', async ({ page }) => {
  303. const mock = new GuideMock()
  304. mock.status = 'REVIEW'
  305. const content = completeContent()
  306. const chapters = content.chapters as Json[]
  307. const steps = (chapters[0] as Json).steps as Json[]
  308. const target = steps[0] as Json
  309. delete target.safety
  310. delete target.acceptance
  311. target.hotspot = false
  312. target.parameters = { 拧紧力矩: '220 N·m' }
  313. mock.content = content
  314. await mock.install(page)
  315. await page.goto('/content/ofd/901/preflight')
  316. await expect(page.locator('.preflight-summary')).toBeVisible()
  317. expect(Number(await statValue(page, '阻断'))).toBeGreaterThan(0)
  318. await expect(page.getByRole('button', { name: '进入发布确认' })).toBeDisabled()
  319. await expect(page.locator('.check-list')).toContainText('安全要求完整')
  320. await expect(page.locator('.check-list')).toContainText('热点交互完整')
  321. await page.screenshot({ path: path.join(SHOTS, '09-预检阻断.png'), fullPage: true })
  322. })
  323. test('未发布草稿不能生成正式交付物', async ({ page }) => {
  324. const mock = new GuideMock()
  325. await mock.install(page)
  326. await page.goto('/content/ofd/901/publish')
  327. await expect(page.locator('.delivery-panel')).toContainText('完成复核与发布后方可生成正式交付物')
  328. await expect(page.getByRole('button', { name: 'OFD 静态版式' })).toBeDisabled()
  329. await expect(page.getByRole('button', { name: '单机离线包' })).toBeDisabled()
  330. await page.screenshot({ path: path.join(SHOTS, '10-草稿禁止交付.png'), fullPage: true })
  331. })
  332. })