yeg
2026-09-05 fe08695ed46eb0834a0a0cf94ec70c77c3b27da4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
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()
}