yeg
2026-09-08 af9b86826fd84553f659df71e1de67d8df35ff91
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
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
 
const stack = new Ticket04Stack()
test.use({ actionTimeout: 20_000 })
test.beforeAll(async () => { await stack.start(); seed() })
test.afterAll(async () => stack.stop())
const batteries = [8601, 8602, 8603, 8604, 8605]
const testId = (battery: number) => battery === 8602 ? 8601 : battery
const selections = batteries.map(id => ({ testId: testId(id), testBatteryId: id }))
const key = (id: number, cycle: number) => `${testId(id)}|${id}|${cycle}`
const fields = [{ key: 'note', scope: 'detail', kind: 'manual', displayName: '人工说明', unit: '' }]
async function login(page: Page, username = 'admin') {
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill(username)
  await page.locator('input[autocomplete="current-password"]').fill(username === 'admin' ? 'admin123' : 'Report123!')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
}
async function api(page: Page, path: string, method = 'GET', data?: unknown, code = 200) {
  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
  const body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json()
  expect(body.code, `${path}: ${body.msg}`).toBe(code)
  return body.data ?? body
}
function seed() {
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8601,'工步甲组','型号'),(8603,'工步乙组','型号');
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8601,'化成'),(8609,'其他类型');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
      VALUES ${[8601, 8603, 8604, 8605].map(id => `(${id},'工步测试${id}',1,1,8601,'隔离验收','COMPLETED','ENDED')`).join(',')},(8608,'未完成化成',1,1,8601,'隔离验收','WAITING','NOT_STARTED'),(8609,'其他类型测试',1,1,8609,'隔离验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
      VALUES ${[...batteries, 8608, 8609].map(id => `(${id},${testId(id)},${id <= 8602 ? 8601 : 8603},'${id === 8602 ? '2号' : '同名电池'}',${id})`).join(',')};
    INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,step_type,voltage_v,current_a,temperature,positive_potential,negative_potential,content_sha256)
      VALUES ${batteries.flatMap(id => [1, 3].flatMap(cycle => [1, 2, 3].flatMap(step => [0, 37, 145].map((seconds, point) => {
        const seq = cycle * 1000 + step * 10 + point
        const time = (delay: number) => new Date(Date.UTC(2026, 8, cycle, step, 0, seconds + delay, 123)).toISOString().replace('T', ' ').replace('Z', '')
        const type = step === 3 ? 'dc' : id === 8605 ? (step === 1 ? 'mystery' : 'pulse') : step === 1 ? 'cc' : 'cccv'
        return `(${testId(id)},${id},${id},'formation-step','formation:${id}','${seq}',27,1,1,1,${seq},'${time(10)}','${time(0)}','${time(11)}',${cycle},${step},'实际工步${step}','${type}',${step + point / 10},${point === 1 ? 'NULL' : point === 0 ? '0' : '-0.2'},${point === 1 ? 'NULL' : '0'},0.1,-0.2,UNHEX(SHA2('${id}:${seq}',256)))`
      })))).join(',')};
    INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,step_no,step_name,step_type,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,voltage_v,content_sha256)
      VALUES ${batteries.flatMap(id => [1, 3].flatMap(cycle => [1, 2, 3].map(step => {
        const type = step === 3 ? 'dc' : id === 8605 ? (step === 1 ? 'mystery' : 'pulse') : step === 1 ? 'cc' : 'cccv'
        return `(${testId(id)},${id},${id},'formation-step','formation:${id}','${cycle}:${step}','${cycle}:${step}',${cycle},${step},'实际工步${step}','${type}','2026-09-01 00:00:00','2026-09-01 00:00:01','Asia/Hong_Kong','2026-09-01 08:00:00',${cycle === 3 && step === 2 ? 'NULL' : step === 1 ? '4.111' : step === 2 ? '4.222' : '2.555'},UNHEX(SHA2('${id}:${cycle}:${step}',256)))`
      }))).join(',')};
  `)
}
 
test('化成实际充放电工步完整冻结、未知多选、图例设置、旧版及多页打印独立', async ({ page }) => {
  await login(page)
  const errors: string[] = []
  page.on('pageerror', error => errors.push(error.message))
  const sources = await api(page, 'formation-report/sources')
  expect(sources.some((item: any) => [8608, 8609].includes(item.selection.testId))).toBe(false)
  for (const selection of [{ testId: 8601, testBatteryId: 8603 }, { testId: 8608, testBatteryId: 8608 }, { testId: 8609, testBatteryId: 8609 }]) await api(page, 'formation-report/preview', 'POST', [selection], 500)
  const matrix = await api(page, 'formation-report/preview', 'POST', selections)
  expect(matrix.cycleNos).toEqual([1, 3])
  expect(matrix.items).toHaveLength(5)
  expect(matrix.items[0].chargeSteps['1'].map((step: any) => step.direction)).toEqual(['CHARGE', 'CHARGE', 'DISCHARGE'])
  for (const item of matrix.items) for (const cycle of [1, 3]) for (const step of [1, 2, 3]) {
    const range = item.stepCurves[cycle][step]
    expect(range.records).toHaveLength(3)
    expect(range.zeroTime).toBe(Date.UTC(2026, 8, cycle, step, 0, 0, 123))
    expect(range.records.every((record: any) => record.stepNo === step && record.cycleNo === cycle)).toBe(true)
  }
  const base = { name: '归属校验', description: '', selections, settings: { fields, values: {}, chargeStepSelections: {}, manualChargeSteps: { [key(8605, 1)]: [1, 2] } } }
  for (const settings of [
    { ...base.settings, manualChargeSteps: { [key(8605, 1)]: [1, 99] } },
    { ...base.settings, manualChargeSteps: { [key(8601, 1)]: [1] } },
    { ...base.settings, curveStepSelections: { [key(8601, 1)]: { charge: [3], discharge: [] } } },
    { ...base.settings, curveStepSelections: { '999|999|1': { charge: [1], discharge: [] } } },
  ]) await api(page, 'formation-report', 'POST', { ...base, settings }, 500)
 
  await page.goto('/report-formation/new')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('请先保存报表后再打印', { exact: true })).toBeVisible()
  await page.locator('.source-panel .el-select').click()
  for (const id of batteries) await page.getByRole('option', { name: `工步测试${testId(id)} / ${id <= 8602 ? '工步甲组' : '工步乙组'} / ${id === 8602 ? '2号' : '同名电池'}`, exact: true }).click()
  await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape')
  await expect(page.locator('[data-group="charge"] .chart-card')).toHaveCount(5)
  await expect(page.locator('[data-group="discharge"] .chart-card')).toHaveCount(5)
  await expect(page.locator('.cycle-card[data-cycle="1"] .charge-termination-row')).toHaveCount(2)
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.111 V')
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.222 V')
  await expect(page.locator('.cycle-card[data-cycle="3"] .charge-termination-row').nth(1)).toContainText('缺失')
  const unknown = page.getByRole('combobox', { name: '未知充电工步 工步测试8605 同名电池 循环1', exact: true })
  await unknown.click()
  for (const step of [1, 2]) await page.getByRole('option', { name: `${step} 实际工步${step}`, exact: true }).click()
  await unknown.press('Escape')
  await expect(page.locator('.cycle-card[data-cycle="1"] .charge-termination-row').nth(1).locator('td').last()).toContainText('4.222 V')
  const chargeChoice = page.getByRole('combobox', { name: '充电工步 工步测试8601 同名电池', exact: true })
  await chargeChoice.click()
  await page.getByRole('option', { name: '1 实际工步1', exact: true }).click()
  await chargeChoice.press('Escape')
  await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.111 V')
  const legend = page.locator('[data-group="charge"] .chart-legend-item').filter({ hasText: '工步测试8601 · 工步甲组 / 同名电池 · 循环1 工步2' })
  await legend.getByRole('button').click()
  await expect(legend.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
  await legend.locator('.el-color-picker').click()
  const panel = page.locator('.el-color-picker__panel:visible')
  await panel.locator('input').fill('#123456')
  await panel.getByRole('button', { name: /确定|确认|OK/ }).click()
  await expect(legend.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
  await page.getByPlaceholder('请输入报表名称').fill('化成工步独立验收')
  await page.getByRole('button', { name: '保存报表', exact: true }).click()
  await expect(page).toHaveURL(/mode=view/)
  const id = Number(page.url().match(/formation\/(\d+)/)![1])
  const v1 = await api(page, `formation-report/${id}`)
  expect(v1.curveMode).toBe('actual-steps')
  expect(v1.settings.manualChargeSteps[key(8605, 1)]).toEqual([1, 2])
  expect(v1.settings.curveStepSelections[key(8601, 1)].charge).toEqual([2])
  expect(v1.settings.hiddenSeriesKeys).toEqual([`${key(8601, 1)}|2`])
  expect(v1.settings.seriesColors[`${key(8601, 1)}|2`]).toBe('#123456')
  expect(v1.units.current).toBe('A'); expect(v1.displayUnits.current).toBe('mA')
  const options = await page.evaluate(async (record) => {
    const path = '/src/utils/formationStepChart.ts'
    const { formationStepChartOption } = await import(path)
    return ['charge', 'discharge'].map(group => ['current', 'positiveVoltage'].map(metric => formationStepChartOption({ key: metric, unit: metric === 'current' ? 'mA' : 'V' }, record.items, 1, group, record.settings)))
  }, v1)
  for (const group of options) {
    expect(group[0].series[0].data).toEqual([[10, 0], [47, null], [155, -200]])
    expect(group[1].series[0].data).toEqual([[0, 0.1], [37, 0.1], [145, 0.1]])
    expect(group[0].yAxis[0].min).toBe(group[0].yAxis[1].min)
    expect(group[0].yAxis[0].max).toBe(group[0].yAxis[1].max)
  }
  expect(options[0][0].series.some((series: any) => series.name.includes('循环1 工步3'))).toBe(false)
  expect(options[1][0].series.every((series: any) => series.name.includes('循环1 工步3'))).toBe(true)
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await page.locator('.metrics-panel').scrollIntoViewIfNeeded()
    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    expect(await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
      const bytes = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
      let colored = 0
      for (let i = 0; i < bytes.length; i += 4) if (bytes[i + 3]! > 0 && Math.max(bytes[i]!, bytes[i + 1]!, bytes[i + 2]!) - Math.min(bytes[i]!, bytes[i + 1]!, bytes[i + 2]!) > 50) colored++
      return colored
    })).toBeGreaterThan(100)
    await page.screenshot({ path: `tests/e2e/.artifacts/formation-steps-${width}.png` })
  }
  await page.setViewportSize({ width: 1440, height: 1000 })
  await page.getByRole('button', { name: '编辑', exact: true }).click()
  await page.locator('.curve-cycle-select').click()
  await page.getByRole('option', { name: '循环3', exact: true }).click()
  await page.locator('.metric-switches .el-checkbox').filter({ hasText: '温度' }).click()
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  await expect(page.getByText('有未保存修改,请先保存新版本后再打印', { exact: true })).toBeVisible()
  await page.getByPlaceholder('请输入修改说明').fill('保存循环和显示设置')
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  await expect(page).toHaveURL(/mode=view/)
  await page.reload()
  await expect(page.locator('.curve-cycle-select')).toContainText('循环3')
  await expect(page.locator('.chart-card')).toHaveCount(8)
  const v2 = await api(page, `formation-report/${id}`)
  expect(v2.items).toEqual(v1.items)
  expect(v2.settings.activeCycle).toBe(3)
  await api(page, `formation-report/${id}/versions`, 'POST', { name: '陈旧', description: '', baseVersionNo: 1, changeSummary: '陈旧拒绝', settings: v1.settings }, 500)
  stack.executeIsolatedSql('UPDATE t_test_step_record SET voltage_v=9 WHERE test_id=8601; UPDATE t_test_realtime_record SET current_a=99 WHERE test_id=8601')
  const v3 = await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动重读' })
  expect(v3.items[0].stepCurves['1']['1'].records.every((row: any) => row.current === 99)).toBe(true)
  expect(v3.items[0].chargeSteps['1'][0].endVoltage).toBe(9)
  expect((await api(page, `formation-report/${id}/versions/1`)).items).toEqual(v1.items)
  for (const sourceId of [8601, 8603, 8604, 8605]) await api(page, `test/${sourceId}`, 'DELETE')
  await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '已删源拒绝' }, 500)
  const edited = await api(page, `formation-report/${id}/versions`, 'POST', { name: '删源后普通编辑', description: '', baseVersionNo: 3, changeSummary: '快照编辑', settings: v3.settings })
  expect(edited.items).toEqual(v3.items)
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  for (const version of [1, 2]) {
    await page.goto(`/report-formation/${id}/edit?mode=view&version=${version}`)
    // 查看时切换循环不是可编辑草稿,不应阻止指定保存版打印。
    await page.locator('.curve-cycle-select').click()
    await page.getByRole('option', { name: '循环1', exact: true }).click()
    const popupPromise = page.waitForEvent('popup')
    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
    const popup = await popupPromise
    await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
    await expect(popup.locator('.chart-card')).toHaveCount(version === 1 ? 20 : 16)
    await expect(popup.locator('[data-cycle="1"] .matrix-table').first()).toContainText('4.111 V')
    await expect(popup.locator('[data-cycle="1"] .matrix-table').first()).toContainText('4.222 V')
    await expect(popup.locator('[data-cycle="1"] .matrix-table').last()).toContainText('4.222 V')
    await expect(popup.locator('[data-cycle="3"] .matrix-table').first()).toContainText('缺失')
    await expect(popup.locator('.hidden-series').first()).toContainText('循环1 工步2')
    await popup.pdf({ path: `tests/e2e/.artifacts/formation-steps-v${version}.pdf`, preferCSSPageSize: true, printBackground: true })
    await popup.screenshot({ path: `tests/e2e/.artifacts/formation-steps-print-v${version}.png`, fullPage: true })
    await popup.close()
  }
  // 隔离库模拟真实旧版整循环快照;读取、编辑和打印均不得静默补入新工步范围。
  stack.executeIsolatedSql(`UPDATE t_formation_report_version SET snapshot_json=JSON_REMOVE(snapshot_json,'$.curveMode',${batteries.map((_, index) => `'$.items[${index}].stepCurves'`).join(',')},'$.settings.manualChargeSteps','$.settings.curveStepSelections','$.settings.hiddenSeriesKeys','$.settings.activeCycle') WHERE report_id=${id} AND version_no=1`)
  const legacy = await api(page, `formation-report/${id}/versions/1`)
  await page.goto(`/report-formation/${id}/edit?mode=view&version=1`)
  await expect(page.locator('.step-curve-group')).toHaveCount(0)
  await expect(page.locator('.chart-card')).toHaveCount(5)
  const legacyPopupPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const legacyPopup = await legacyPopupPromise
  await expect(legacyPopup.locator('.chart-card')).toHaveCount(10)
  await expect(legacyPopup.locator('[data-group]')).toHaveCount(0)
  await legacyPopup.close()
  expect((await api(page, `formation-report/${id}/versions/1/print`)).items).toEqual(legacy.items)
  const template = await api(page, 'formation-report/templates', 'POST', { name: '工步字段结构模板', fields })
  const templates = await api(page, 'formation-report/templates')
  expect(templates.find((item: any) => item.id === template).fields).toEqual(fields.map(field => ({ ...field, fieldDefinitionId: null })))
  const modules = await api(page, 'menu/permissionList')
  const permissions = modules.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '化成报表').permissions
  const menuIds = permissions.filter((item: any) => item.menuName === '查看').map((item: any) => item.menuId)
  await api(page, 'role', 'POST', { roleName: 'step_reader', roleKey: 'common', roleSort: 1, status: '0', menuIds })
  const roleList = await api(page, 'role/list?roleName=step_reader')
  await api(page, 'user', 'POST', { userName: 'step_reader', nickName: '工步只读', password: 'Report123!', status: '0', roleIds: [roleList.rows[0].roleId] })
  await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
  await login(page, 'step_reader')
  await api(page, `formation-report/${id}/versions/1/print`, 'GET', undefined, 403)
  await api(page, 'formation-report', 'POST', base, 403)
  await api(page, `formation-report/${id}`, 'DELETE', undefined, 403)
  await api(page, `formation-report/${id}/versions`, 'POST', { name: '拒绝', description: '', baseVersionNo: 4, changeSummary: '拒绝', settings: v3.settings }, 403)
  expect(errors).toEqual([])
})