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([])
|
})
|