import assert from 'node:assert/strict'
|
import { readFile, mkdir } from 'node:fs/promises'
|
import { chromium, expect } from '@playwright/test'
|
import * as XLSX from 'xlsx'
|
|
// 从生产构建拦截静态资源和 REST/SSE,无需启动开发或业务服务。
|
const browser = await chromium.launch({ channel: 'chrome', headless: true })
|
const context = await browser.newContext({ timezoneId: 'Asia/Hong_Kong', viewport: { width: 1440, height: 1000 }, acceptDownloads: true })
|
const page = await context.newPage()
|
const errors = []
|
page.on('pageerror', error => errors.push(error.message))
|
const time = 1788588000123
|
const detail = { testId: 42, testName: 'Ticket11', testStatus: 'COMPLETED', receptionStatus: 'ENDED', startedAtUtc: time, endedAtUtc: time + 60000, createTime: time, batteries: [{ batteryNo: 'B01', collectorId: 7, binding: { channelId: 1, cabinetName: '检测柜一', displayNo: '1' }, collectorChannels: [], fields: [] }], logicStepMappings: [], thresholds: [] }
|
const data = {
|
records: [{ id: 'realtime:1', stableIdentity: 'realtime:1', recordNo: 'RT-1', sourceType: 'SNAPSHOT', batteryNo: 'B01', cycleNo: 2, stepNo: 3, sequenceId: 10, stepName: '恒流放电', deviceTime: time, collectorDeviceTime: time + 500, serverReceivedAt: time + 1000, voltage: 3.7, current: 1.2, capacity: 2.5, energy: 9.25, temperature: 26, positivePotential: 0.5, negativePotential: null, sourceIdentity: 'adapter-a:1:2:3:4', collectorId: 7, extensionMeasurementsJson: '{"HUMIDITY":{"value":41,"unit":"%","channelCode":"S-04"}}' }],
|
steps: [{ id: 'step:1', stableIdentity: 'step:1', recordNo: 'STEP-1', batteryNo: 'B01', cycleNo: 2, stepNo: 3, stepName: '恒流放电', recordedAt: time, serverReceivedAt: time + 1000, capacity: 2.5 }],
|
cycles: [{ id: 'cycle:1', stableIdentity: 'cycle:1', recordNo: 'CYCLE-1', batteryNo: 'B01', cycleNo: 2, recordedAt: time, serverReceivedAt: time + 1000, chargeCapacity: 3, dischargeCapacity: 2.5, efficiency: 83.33 }],
|
}
|
const live = { ...detail, updatedAt: time, sources: [], exceptions: [], testPlan: { currentCycleNo: 2, currentStepNo: 3, plannedCycleCount: 5, rows: [{ rowType: 'STEP', stepNo: 3, cyclePosition: 1, stepName: '设备计划放电', durationSeconds: 60, voltageV: 3.2, executionStatus: '进行中' }] } }
|
let liveReads = 0
|
let unavailable = false
|
let detailUnavailable = false
|
let forbidden = false
|
const requests = []
|
await context.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'ticket11-token'))
|
await page.route('http://ticket11.test/**', async route => {
|
const url = new URL(route.request().url())
|
if (url.pathname.startsWith('/dev-api/')) {
|
requests.push(url.pathname)
|
if (detailUnavailable && url.pathname.endsWith('/system/test/42')) return route.fulfill({ status: 503, json: { code: 503, msg: '测试配置暂时不可用' } })
|
if (forbidden && /\/live(?:-state)?$/.test(url.pathname)) return route.fulfill({ status: 403, json: { code: 403, msg: '实时状态权限不足' } })
|
if (unavailable && /\/live(?:-state)?$/.test(url.pathname)) return route.fulfill({ status: 503, json: { code: 503, msg: '后端暂时不可用' } })
|
let result = detail
|
if (url.pathname.endsWith('/live-state')) { liveReads++; result = live }
|
else if (url.pathname.endsWith('/live')) {
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
return route.fulfill({ contentType: 'text/event-stream', body: `event: snapshot\ndata: ${JSON.stringify(live)}\n\n` })
|
}
|
else if (url.pathname.includes('/data/')) result = data[url.pathname.split('/').at(-1)] || []
|
else if (url.pathname.endsWith('/console-data/actions')) {
|
const action = route.request().postDataJSON()
|
const kind = { REALTIME: 'records', STEP: 'steps', CYCLE: 'cycles' }[action.dataType]
|
if (action.operationType === 'DELETE') data[kind] = data[kind].filter(row => !JSON.parse(action.identitySummary).includes(row.stableIdentity))
|
else for (const item of action.overrides || []) {
|
const row = data[kind].find(row => row.stableIdentity === item.stableIdentity)
|
if (row) Object.assign(row, JSON.parse(item.fieldValues))
|
else data[kind].push({ ...JSON.parse(item.fieldValues), stableIdentity: item.stableIdentity })
|
}
|
}
|
return route.fulfill({ json: { code: 200, data: result } })
|
}
|
const asset = url.pathname.startsWith('/assets/') ? url.pathname.slice(1) : 'index.html'
|
const contentType = asset.endsWith('.js') ? 'text/javascript' : asset.endsWith('.css') ? 'text/css' : 'text/html'
|
return route.fulfill({ contentType, body: await readFile(new URL(`../dist/${asset}`, import.meta.url)) })
|
})
|
try {
|
await page.goto('http://ticket11.test/test-list/42/console?tab=data-list')
|
const panel = page.locator('.console-data-panel')
|
await page.getByText('RT-1', { exact: true }).waitFor()
|
assert.match(await page.locator('body').innerText(), /2026-09-05 14:00:00\.123/)
|
assert.match(await page.locator('body').innerText(), /HUMIDITY.*41.*%/)
|
assert.match(await page.locator('body').innerText(), /S-04/)
|
assert.match(await page.locator('body').innerText(), /adapter-a:1:2:3:4/)
|
await mkdir(new URL('../test-results/ticket11/', import.meta.url), { recursive: true })
|
await page.screenshot({ path: new URL('../test-results/ticket11/source-desktop.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true })
|
await panel.getByRole('button', { name: '编辑', exact: true }).click()
|
const dialog = page.getByRole('dialog')
|
await dialog.locator('label').filter({ hasText: '电压(V)' }).locator('input').fill('4.1256')
|
await dialog.locator('textarea').fill('核对电压')
|
await dialog.getByRole('button', { name: '提交修改', exact: true }).click()
|
await page.getByText('4.1256', { exact: true }).waitFor()
|
await page.reload()
|
await page.getByText('4.1256', { exact: true }).waitFor()
|
data.records[0].voltage = 4.3333
|
const exported = page.waitForEvent('download')
|
await panel.getByRole('button', { name: '下载', exact: true }).click()
|
assert.match(await readFile(await (await exported).path(), 'utf8'), /4.3333/)
|
|
// 实际下载模板再上传,覆盖三种导入字段映射及返回子页。
|
for (const [kind, tab, type] of [['records', '实时', 'REALTIME'], ['steps', '工步', 'STEP'], ['cycles', '循环', 'CYCLE']]) {
|
await panel.getByRole('button', { name: tab, exact: true }).click()
|
const templateDownload = page.waitForEvent('download')
|
await panel.getByRole('button', { name: '下载模板', exact: true }).click()
|
const template = XLSX.read(await readFile(await (await templateDownload).path()))
|
const sheet = template.Sheets[template.SheetNames[0]]
|
const headers = XLSX.utils.sheet_to_json(sheet, { header: 1 })[0]
|
const values = headers.map(header => ({ '记录编号': `IMPORT-${type}`, '电池号': 'B01', '设备时间': '2026-09-05 14:00:00.123', '循环序号': 2, '工步序号': 3, '记录序号': 11, '工步名称': '导入工步', '工步': '导入工步', '温度(℃)': '-', '负电位(V)': '-' }[header] ?? 1.2345))
|
XLSX.utils.sheet_add_aoa(sheet, [values], { origin: 'A2' })
|
await panel.getByRole('button', { name: '导入', exact: true }).click()
|
await page.locator('input[type=file]').setInputFiles({ name: 'ticket11.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', buffer: XLSX.write(template, { type: 'buffer', bookType: 'xlsx' }) })
|
await page.getByText('通过', { exact: true }).waitFor()
|
await page.getByRole('button', { name: '确认导入', exact: true }).click()
|
await page.locator('.el-message-box input').fill('导入核对')
|
await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click()
|
await page.getByText('导入完成', { exact: true }).waitFor()
|
const imported = data[kind].find(row => row.recordNo === `IMPORT-${type}`)
|
assert.equal(imported.recordedAt, time)
|
if (kind === 'records') assert.equal(imported.temperature, null)
|
if (kind === 'cycles') assert.equal(imported.dischargeCapacity, 1.2345)
|
if (kind === 'steps') assert.equal(imported.endVoltage, 1.2345)
|
await page.getByRole('button', { name: '返回数据列表', exact: true }).first().click()
|
await page.getByText(`IMPORT-${type}`, { exact: true }).waitFor()
|
await page.reload()
|
await page.getByText(`IMPORT-${type}`, { exact: true }).waitFor()
|
}
|
await panel.getByRole('button', { name: '实时', exact: true }).click()
|
const original = page.getByRole('row').filter({ has: page.getByText('RT-1', { exact: true }) })
|
await original.getByRole('button', { name: '删除', exact: true }).click()
|
await page.locator('.el-message-box input').fill('移除错误数据')
|
await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click()
|
await original.waitFor({ state: 'detached' })
|
await page.reload()
|
await page.getByText('IMPORT-REALTIME', { exact: true }).waitFor()
|
await expect(panel.getByRole('button', { name: '实时', exact: true })).toHaveAttribute('aria-current', 'page')
|
assert.equal(await page.getByText('RT-1', { exact: true }).count(), 0)
|
await page.getByRole('button', { name: '工步', exact: true }).click()
|
await page.getByText('STEP-1', { exact: true }).waitFor()
|
await page.getByRole('button', { name: '循环', exact: true }).click()
|
await page.getByText('CYCLE-1', { exact: true }).waitFor()
|
await page.reload()
|
await page.getByText('CYCLE-1', { exact: true }).waitFor()
|
await page.getByRole('button', { name: '实时数据', exact: true }).click()
|
await page.getByText('设备计划放电', { exact: true }).waitFor()
|
assert.match(await page.locator('.project-meta').innerText(), /2 \/ 计划 5/)
|
const before = liveReads
|
await page.waitForFunction(() => document.querySelector('.live-error') !== null)
|
await new Promise(resolve => setTimeout(resolve, 2400))
|
assert.ok(liveReads > before, 'SSE 断开后重新读取后端快照')
|
Object.assign(detail, { testStatus: 'RUNNING', receptionStatus: 'RECEIVING' })
|
Object.assign(live, { testStatus: 'RUNNING', receptionStatus: 'RECEIVING', sources: [{ sourceType: 'CABINET_CHANNEL', batteryNo: 'B01', sourceId: 1, deviceStatus: 'ONLINE', latestSnapshot: { voltageV: 3.8123, observedAt: time, units: ['V'] } }] })
|
await page.reload()
|
await expect(page.locator('.metric-list .voltage')).toHaveText('3.8123')
|
await expect(page.locator('.cell-summary')).toContainText('2026-09-05 14:00:00.123')
|
unavailable = true
|
await expect(page.locator('.live-error')).toBeVisible()
|
await expect(page.locator('.metric-list .voltage')).toHaveText('-')
|
live.sources[0].latestSnapshot.voltageV = 4.7123
|
unavailable = false
|
await expect(page.locator('.metric-list .voltage')).toHaveText('4.7123', { timeout: 10000 })
|
Object.assign(detail, { testStatus: 'PAUSED', receptionStatus: 'PAUSED' })
|
Object.assign(live, { testStatus: 'PAUSED', receptionStatus: 'PAUSED' })
|
await page.reload()
|
await expect(page.locator('.reception-state')).toHaveText('暂停接收')
|
await expect(page.locator('.metric-list .voltage')).toHaveText('-')
|
forbidden = true
|
await expect(page.locator('.live-error')).toContainText('权限不足', { timeout: 10000 })
|
forbidden = false
|
detailUnavailable = true
|
await page.reload()
|
await expect(page.locator('.live-error')).toContainText('测试配置暂时不可用')
|
detailUnavailable = false
|
await expect(page.locator('.reception-state')).toHaveText('暂停接收', { timeout: 10000 })
|
assert.ok(requests.every(url => !url.includes('simulator') && !url.includes('overrides')))
|
assert.deepEqual(errors, [])
|
await mkdir(new URL('../test-results/ticket11/', import.meta.url), { recursive: true })
|
await page.screenshot({ path: new URL('../test-results/ticket11/desktop.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true })
|
await page.getByRole('button', { name: '数据列表', exact: true }).click()
|
await panel.getByRole('button', { name: '实时', exact: true }).click()
|
await page.getByText('IMPORT-REALTIME', { exact: true }).waitFor()
|
await page.screenshot({ path: new URL('../test-results/ticket11/data-desktop.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true })
|
await page.setViewportSize({ width: 760, height: 900 })
|
await page.screenshot({ path: new URL('../test-results/ticket11/data-narrow.png', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), fullPage: true })
|
console.log('Ticket11 浏览器契约通过')
|
} finally {
|
await browser.close()
|
}
|