|
- import { expect, test, type Page, type Route } from '@playwright/test'
- import fs from 'node:fs'
- import path from 'node:path'
-
- /**
- * 作业指导书全流程回归:编制 → 送审 → 审批时间线 → 预览 → 预检 → 批准 → 发布 → 交付物下载。
- *
- * 内容制作接口按 wiki/11-内容制作模块.md 的契约打桩(与 teaching-module 系列同一做法),
- * 验证前端流程、生命周期流转请求、预检口径与浏览器内交付物生成,不依赖数据库与在线服务。
- * 打桩的状态流转表与服务端 REVIEW_TRANSITIONS 一致,契约分叉会在这里暴露。
- */
-
- type Json = Record<string, unknown>
-
- const SHOTS = path.resolve('artifacts/content-guide-lifecycle')
- fs.mkdirSync(SHOTS, { recursive: true })
-
- const envelope = (data: unknown) => ({ code: 200, message: 'OK', data, timestamp: Date.now(), requestId: 'ute2e-ofd' })
- const ok = (route: Route, data: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(envelope(data)) })
- const fail = (route: Route, status: number, message: string) => route.fulfill({
- status, contentType: 'application/json', body: JSON.stringify({ code: status * 100, message, data: null, timestamp: Date.now(), requestId: 'ute2e-ofd' }),
- })
- const pageOf = (records: unknown[]) => ({ records, total: records.length, page: 1, size: 20 })
- const GUIDE_IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAADZSiLoAAAAGUlEQVR4nGP8z8DAwMDAxMDAwMDAAAALAAH+Q0KzAAAAAElFTkSuQmCC', 'base64')
-
- const step = (id: string, title: string): Json => ({
- id, title, order: 1,
- summary: '确认整机停机并系统卸压后,按对角顺序拆除缸筒固定螺栓。',
- safety: '全程佩戴护目镜与防割手套,作业区设置警戒。',
- tools: '液压扳手、专用吊具',
- acceptance: '缸筒外观无划痕,接口无渗漏。',
- part: '支腿液压缸', anchor: '右前支腿默认视角', animation: '支腿拆卸', media: 'GUIDE_IMAGE_01',
- stepType: '拆卸', duration: '12:30', status: '已完成', hotspot: true, start: 0, end: 12,
- parameters: { 拧紧力矩: '220 N·m', 系统压力: '不高于 0.5 MPa', 环境温度: '5 至 35 摄氏度' },
- })
-
- const completeContent = (): Json => ({
- equipment: 'GX-200 全地形起重机',
- owner: '整机维修保障中心',
- specification: 'GB/T 33190-2016',
- chapters: [
- { id: 'c1', number: '01', title: '作业准备', order: 1, steps: [step('s1', '停机挂牌与卸压')] },
- { id: 'c2', number: '02', title: '缸体拆卸', order: 2, steps: [step('s2', '拆卸支腿液压缸')] },
- ],
- resources: [{ id: 'r1', assetCode: 'GUIDE_IMAGE_01', type: '图片', name: '支腿总成配图', status: 'READY' }],
- partsCatalog: [{ id: 'p1', name: '支腿液压缸' }],
- three: { parts: ['支腿液压缸'], markers: [], selectedPart: '支腿液压缸' },
- publish: { version: 'V1.2.0', note: '首次正式发布', channels: ['OFD静态版式', '单机离线包'], publishedAt: '' },
- })
-
- const TRANSITIONS: Record<string, { from: string[]; to: string; commentRequired: boolean }> = {
- SUBMIT: { from: ['DRAFT', 'REJECTED'], to: 'REVIEW', commentRequired: false },
- WITHDRAW: { from: ['REVIEW'], to: 'DRAFT', commentRequired: false },
- APPROVE: { from: ['REVIEW'], to: 'APPROVED', commentRequired: false },
- REJECT: { from: ['REVIEW'], to: 'REJECTED', commentRequired: true },
- REVOKE: { from: ['PUBLISHED'], to: 'WITHDRAWN', commentRequired: true },
- REVISE: { from: ['REJECTED', 'WITHDRAWN', 'SUPERSEDED'], to: 'DRAFT', commentRequired: false },
- }
-
- class GuideMock {
- readonly requests: Array<{ method: string; path: string; body: Json }> = []
- content: Json = completeContent()
- status = 'DRAFT'
- version = 3
- publishedVersionId: string | null = null
- readCount = 0
- downloadCount = 0
- reviews: Json[] = []
-
- private assets() {
- return [{
- id: 'asset-image-01', versionId: '11', code: 'GUIDE_IMAGE_01', name: '支腿总成配图',
- type: 'IMAGE', storageUri: 'content://sha256/guide-image-01', mimeType: 'image/png',
- sizeBytes: GUIDE_IMAGE.length, sha256: 'a'.repeat(64), metadata: { format: 'PNG' }, status: 'READY', sortOrder: 0,
- }]
- }
-
- private project() {
- return {
- id: '901', type: 'GUIDE', code: 'OFD-2026-0031', name: '支腿液压缸中修作业指导书',
- description: '整机支腿液压缸中修标准作业', categoryCode: 'MAINTENANCE', coverUri: '',
- status: this.status, version: this.version,
- currentVersionId: '11', currentVersionNo: 3, currentVersionCode: 'V1.2.0', currentVersionStatus: 'DRAFT',
- publishedVersionId: this.publishedVersionId,
- ownerUserId: '7', ownerName: '内容作者', departmentId: '20', departmentName: '维修保障中心',
- addTime: 1786387200, updateTime: 1786387200, publishedTime: this.publishedVersionId ? 1786387200 : null,
- usageCount: 0, readCount: this.readCount, downloadCount: this.downloadCount,
- dependencyCount: 0, assetCount: this.assets().length,
- }
- }
-
- private version11(status = 'DRAFT') {
- return {
- id: '11', projectId: '901', versionNo: 3, versionCode: 'V1.2.0', status,
- content: this.content, dependencies: [], assets: this.assets(),
- createdByName: '内容作者', publishedByName: status === 'PUBLISHED' ? '内容作者' : '',
- publishedTime: status === 'PUBLISHED' ? 1786387200 : null,
- addTime: 1786387200, updateTime: 1786387200, dataVersion: 0, changeSummary: '',
- }
- }
-
- private detail() {
- return { project: this.project(), currentVersion: this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT') }
- }
-
- async install(page: Page) {
- await page.addInitScript(() => {
- sessionStorage.setItem('unreal-tran:web:access-token:v1', 'ute2e-ofd-token')
- })
- await page.route('**/api/auth/v1/auth/me', (route) => ok(route, {
- user: { id: '1', username: 'admin', displayName: '管理员', departmentId: '20', departmentName: '维修保障中心', mustChangePassword: false, version: 1 },
- activeRoleId: 'role-admin',
- roles: [{ id: 'role-admin', code: 'admin', name: '管理员', shortName: '管', status: 1, builtIn: 1, isSuperAdmin: 1, dataScopeCode: 'ALL' }],
- permissions: ['content.ofd', 'content.model', 'content.scene', 'content.training', 'content.create', 'content.update', 'content.publish', 'content.delete'],
- authorizationMode: 'SINGLE_ACTIVE', loginTime: 1786387200,
- }))
- await page.route('**/api/auth/v1/menus/navigation', (route) => ok(route, []))
- await page.route('**/api/tran/v1/content/**', (route) => this.handle(route))
- }
-
- private async handle(route: Route) {
- const request = route.request()
- const url = new URL(request.url())
- const target = url.pathname.replace(/^.*\/v1\/content/, '')
- const method = request.method()
- let body: Json = {}
- try {
- const text = request.postData()
- if (text) body = JSON.parse(text) as Json
- } catch { body = {} }
- this.requests.push({ method, path: target, body })
-
- if (method === 'GET' && target === '/projects') return ok(route, pageOf([this.project()]))
- if (method === 'GET' && target === '/projects/summary') {
- return ok(route, { total: 1, draft: 0, review: 0, published: 0, byType: [], byTrainingMode: [] })
- }
- if (method === 'GET' && target === '/catalog') return ok(route, pageOf([]))
- if (method === 'GET' && target === '/projects/901') return ok(route, this.detail())
- if (method === 'PUT' && target === '/projects/901') {
- if (body.content && typeof body.content === 'object') this.content = body.content as Json
- this.version += 1
- return ok(route, this.detail())
- }
- if (method === 'GET' && target === '/projects/901/versions') {
- return ok(route, pageOf([this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT')]))
- }
- if (method === 'GET' && target === '/projects/901/versions/11') {
- return ok(route, this.version11(this.status === 'PUBLISHED' ? 'PUBLISHED' : 'DRAFT'))
- }
- if (method === 'GET' && target === '/projects/901/assets/asset-image-01/content') {
- return route.fulfill({ status: 200, contentType: 'image/png', body: GUIDE_IMAGE })
- }
- if (method === 'GET' && target === '/projects/901/reviews') return ok(route, [...this.reviews].reverse())
- if (method === 'POST' && target === '/projects/901/review') {
- const action = String(body.action ?? '')
- const transition = TRANSITIONS[action]
- if (!transition) return fail(route, 400, '动作无效')
- if (!transition.from.includes(this.status)) return fail(route, 409, '起始状态不匹配')
- const comment = String(body.comment ?? '').trim()
- if (transition.commentRequired && !comment) return fail(route, 400, '该动作必须填写处理意见')
- this.reviews.push({
- id: String(this.reviews.length + 1), projectId: '901', versionId: '11', action,
- fromStatus: this.status, toStatus: transition.to, comment,
- actorUserId: '1', actorName: '管理员', operateTime: 1786387200 + this.reviews.length * 60,
- })
- this.status = transition.to
- this.version += 1
- return ok(route, this.detail())
- }
- if (method === 'POST' && target === '/projects/901/publish') {
- if (!['REVIEW', 'APPROVED'].includes(this.status)) return fail(route, 409, '指导书必须先提交复核,复核中或已批准才能发布')
- this.status = 'PUBLISHED'
- this.publishedVersionId = '11'
- this.version += 1
- return ok(route, this.detail())
- }
- if (method === 'POST' && target === '/projects/901/metrics') {
- if (String(body.metric) === 'READ') this.readCount += 1
- if (String(body.metric) === 'DOWNLOAD') this.downloadCount += 1
- return ok(route, {})
- }
- return fail(route, 404, `未打桩的内容接口 ${method} ${target}`)
- }
- }
-
- const statValue = async (page: Page, label: string) => (
- page.locator('.preflight-summary article', { hasText: label }).locator('b').first().innerText()
- )
-
- test.describe('作业指导书生命周期与交付物', () => {
- test('编制 → 预览 → 预检 → 送审 → 批准 → 发布 → 交付物下载', async ({ page }) => {
- const mock = new GuideMock()
- await mock.install(page)
- const consoleErrors: string[] = []
- page.on('pageerror', (error) => consoleErrors.push(error.message))
-
- // 0. 资源导入:只能展示真实版本资产,并提供受控图片上传入口
- await page.goto('/content/ofd/901/studio?view=resources')
- await expect(page.locator('button', { hasText: '上传配图' })).toBeVisible()
- const resourceTable = page.locator('.single-panel .el-table').first()
- await expect(resourceTable).toContainText('GUIDE_IMAGE_01')
- await expect(resourceTable).toContainText('已就绪')
-
- // 1. 编制:工作台读到草稿,能改内容并保存
- await page.goto('/content/ofd/901/studio?view=editor')
- await expect(page.locator('.guide-flow-panel')).toBeVisible()
- await expect(page.locator('.chapter-panel')).toContainText('作业准备')
- await page.locator('.step-row', { hasText: '停机挂牌与卸压' }).first().click()
- const titleInput = page.locator('.step-editor .form-grid input').first()
- await titleInput.fill('停机挂牌与系统卸压')
- // el-input 的 change 在失焦时触发,脏标记要等失焦后才置位。
- await titleInput.blur()
- await expect(page.locator('.studio-dirty')).toBeVisible()
- await page.getByRole('button', { name: /^保存$/ }).click()
- await expect(page.locator('.studio-dirty')).toHaveCount(0)
- const saved = mock.requests.filter((item) => item.method === 'PUT')
- expect(saved.length).toBeGreaterThan(0)
- await page.screenshot({ path: path.join(SHOTS, '01-编制.png'), fullPage: true })
-
- // 2. 送审:再次修改但不手工保存,生命周期动作必须先保存最新草稿再发起 SUBMIT
- await titleInput.fill('停机挂牌、系统卸压与复核确认')
- await titleInput.blur()
- await expect(page.locator('.studio-dirty')).toBeVisible()
- const putsBeforeSubmit = mock.requests.filter((item) => item.method === 'PUT').length
- await page.getByRole('button', { name: '提交复核' }).click()
- await page.getByRole('button', { name: '确认提交复核' }).click()
- await expect.poll(() => mock.status).toBe('REVIEW')
- expect(mock.requests.filter((item) => item.method === 'PUT').length).toBe(putsBeforeSubmit + 1)
- expect(((mock.content.chapters as Json[])[0]!.steps as Json[])[0]!.title).toBe('停机挂牌、系统卸压与复核确认')
- await page.screenshot({ path: path.join(SHOTS, '02-送审.png'), fullPage: true })
-
- // 3. 审批时间线读到刚才的流转
- await page.goto('/content/ofd/901/studio?view=feedback')
- await expect(page.locator('.single-panel')).toContainText('提交复核')
- await expect(page.locator('.single-panel')).toContainText('草稿 → 审核中')
- await page.screenshot({ path: path.join(SHOTS, '03-审批时间线.png'), fullPage: true })
-
- // 4. 预览:章节目录与正文来自当前草稿
- await page.goto('/content/ofd/901/preview')
- await expect(page.locator('.guide-toc')).toContainText('缸体拆卸')
- await expect(page.locator('.guide-document')).toContainText('停机挂牌、系统卸压与复核确认')
- await page.screenshot({ path: path.join(SHOTS, '04-预览.png'), fullPage: true })
-
- // 5. 预检:24 项内容检查 + 3 项平台检查全部通过
- await page.goto('/content/ofd/901/preflight')
- await expect(page.locator('.preflight-summary')).toBeVisible()
- expect(await statValue(page, '通过')).toBe('26')
- expect(await statValue(page, '待确认')).toBe('1')
- expect(await statValue(page, '阻断')).toBe('0')
- expect(await statValue(page, '完成度')).toContain('100')
- const enterPublish = page.getByRole('button', { name: '进入发布确认' })
- await expect(enterPublish).toBeEnabled()
- await page.screenshot({ path: path.join(SHOTS, '05-预检.png'), fullPage: true })
-
- // 6. 批准 + 发布
- await enterPublish.click()
- await expect(page).toHaveURL(/\/content\/ofd\/901\/publish$/)
- await page.getByRole('button', { name: '复核通过' }).click()
- await page.getByRole('button', { name: '确认复核通过' }).click()
- await expect.poll(() => mock.status).toBe('APPROVED')
- await page.getByRole('button', { name: '确认发布' }).click()
- await page.getByRole('button', { name: '确认发布' }).last().click()
- await expect.poll(() => mock.status).toBe('PUBLISHED')
- await expect(page.locator('.publish-confirmation h2')).toContainText('当前版本已发布')
- await page.screenshot({ path: path.join(SHOTS, '06-发布.png'), fullPage: true })
-
- // 7. 交付物:OFD 静态版式与单机离线包都能生成、自校验通过并触发下载
- const ofdDownload = page.waitForEvent('download')
- await page.getByRole('button', { name: 'OFD 静态版式' }).click()
- const ofdFile = await ofdDownload
- expect(ofdFile.suggestedFilename()).toMatch(/\.ofd$/)
- const ofdPath = path.join(SHOTS, 'delivery.ofd')
- await ofdFile.saveAs(ofdPath)
- const ofdBytes = fs.readFileSync(ofdPath)
- expect(ofdBytes.length).toBeGreaterThan(4096)
- expect(ofdBytes.subarray(0, 4).toString('hex')).toBe('504b0304')
- expect(ofdBytes.toString('latin1')).toContain('META-INF/EXPORT-METADATA.json')
- await expect(page.locator('.delivery-report')).toContainText('OFD 静态版式')
- await expect(page.locator('.delivery-report')).toContainText('发布版本 V1.2.0')
- const hash = await page.locator('.delivery-hash dd').innerText()
- expect(hash).toMatch(/^[a-f0-9]{64}$/)
- // 交付信息必须单列堆叠,否则文件名与 64 位哈希会被挤成折行。
- const rows = page.locator('.delivery-report > div')
- const firstRow = await rows.nth(0).boundingBox()
- const secondRow = await rows.nth(1).boundingBox()
- expect(secondRow!.y).toBeGreaterThanOrEqual(firstRow!.y + firstRow!.height - 1)
-
- const offlineDownload = page.waitForEvent('download')
- await page.getByRole('button', { name: '单机离线包' }).click()
- const offlineFile = await offlineDownload
- expect(offlineFile.suggestedFilename()).toMatch(/离线阅读版\.html$/)
- const offlinePath = path.join(SHOTS, 'delivery-offline.html')
- await offlineFile.saveAs(offlinePath)
- const offlineHtml = fs.readFileSync(offlinePath, 'utf8')
- expect(offlineHtml.startsWith('<!doctype html>')).toBe(true)
- expect(offlineHtml).toContain('Content-Security-Policy')
- expect(offlineHtml).toContain('data-role="toc"')
- expect(offlineHtml.match(/data-step-page="\d+"/g)?.length).toBe(2)
- expect(offlineHtml.match(/data:image\/png;base64/g)?.length).toBe(2)
- expect(/<(?:script[^>]*src|link[^>]*href|img[^>]*src)=["']https?:/i.test(offlineHtml)).toBe(false)
- await page.screenshot({ path: path.join(SHOTS, '07-交付物.png'), fullPage: true })
-
- // 8. 两次下载各回报一次 DOWNLOAD 统计
- expect(mock.downloadCount).toBe(2)
- const metrics = mock.requests.filter((item) => item.path === '/projects/901/metrics')
- expect(metrics.map((item) => item.body.metric)).toEqual(['DOWNLOAD', 'DOWNLOAD'])
-
- expect(consoleErrors, `页面运行时错误:${consoleErrors.join(' | ')}`).toEqual([])
- })
-
- test('撤回发布必须填写处理意见', async ({ page }) => {
- const mock = new GuideMock()
- mock.status = 'PUBLISHED'
- mock.publishedVersionId = '11'
- await mock.install(page)
-
- await page.goto('/content/ofd/901/studio?view=editor')
- await page.getByRole('button', { name: '撤回发布' }).click()
- const dialog = page.locator('.el-dialog')
- await expect(dialog).toBeVisible()
- await dialog.getByRole('button', { name: '确认撤回发布' }).click()
- await expect(page.locator('.el-message')).toContainText('必须填写处理意见')
- expect(mock.status).toBe('PUBLISHED')
-
- await dialog.locator('textarea').fill('发现第 2 步拧紧力矩参数有误')
- await dialog.getByRole('button', { name: '确认撤回发布' }).click()
- await expect.poll(() => mock.status).toBe('WITHDRAWN')
- const record = mock.reviews.at(-1)
- expect(record?.action).toBe('REVOKE')
- expect(record?.comment).toBe('发现第 2 步拧紧力矩参数有误')
- await page.screenshot({ path: path.join(SHOTS, '08-撤回发布.png'), fullPage: true })
- })
-
- test('要素缺失时预检阻断并禁用发布确认', async ({ page }) => {
- const mock = new GuideMock()
- mock.status = 'REVIEW'
- const content = completeContent()
- const chapters = content.chapters as Json[]
- const steps = (chapters[0] as Json).steps as Json[]
- const target = steps[0] as Json
- delete target.safety
- delete target.acceptance
- target.hotspot = false
- target.parameters = { 拧紧力矩: '220 N·m' }
- mock.content = content
- await mock.install(page)
-
- await page.goto('/content/ofd/901/preflight')
- await expect(page.locator('.preflight-summary')).toBeVisible()
- expect(Number(await statValue(page, '阻断'))).toBeGreaterThan(0)
- await expect(page.getByRole('button', { name: '进入发布确认' })).toBeDisabled()
- await expect(page.locator('.check-list')).toContainText('安全要求完整')
- await expect(page.locator('.check-list')).toContainText('热点交互完整')
- await page.screenshot({ path: path.join(SHOTS, '09-预检阻断.png'), fullPage: true })
- })
-
- test('未发布草稿不能生成正式交付物', async ({ page }) => {
- const mock = new GuideMock()
- await mock.install(page)
-
- await page.goto('/content/ofd/901/publish')
- await expect(page.locator('.delivery-panel')).toContainText('完成复核与发布后方可生成正式交付物')
- await expect(page.getByRole('button', { name: 'OFD 静态版式' })).toBeDisabled()
- await expect(page.getByRole('button', { name: '单机离线包' })).toBeDisabled()
- await page.screenshot({ path: path.join(SHOTS, '10-草稿禁止交付.png'), fullPage: true })
- })
- })
|