import { expect, test, type Page } from '@playwright/test'
|
import { Ticket04Stack } from './support/ticket04-stack'
|
|
const stack = new Ticket04Stack()
|
const kinds = ['compare', 'formation', 'splice'] as const
|
type Kind = typeof kinds[number]
|
const sources = [9301, 9302, 9303, 9304]
|
const selection = (id: number, battery = id) => ({ testId: id, testBatteryId: battery })
|
const compareSelections = [9301, 9303].flatMap(id => [1, 3].map(cycleNo => ({ ...selection(id), cycleNo, stepNo: 1, logicalNo: null })))
|
const colors = ['#123456', '#3978f6', '#f08a3e', '#109854']
|
|
test.use({ actionTimeout: 20_000 })
|
test.beforeAll(async () => { await stack.start(); seed() })
|
test.afterAll(async () => stack.stop())
|
|
async function login(page: Page, username = 'admin') {
|
await page.goto('/login')
|
await page.evaluate(() => {
|
localStorage.removeItem('battery_detection_auth_token')
|
localStorage.removeItem('battery_detection_auth_user')
|
})
|
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 request(page: Page, path: string, method = 'GET', data?: unknown) {
|
const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
|
return (await page.request.fetch(`${stack.backendUrl}/system/${path}`, {
|
method, data, headers: { Authorization: `Bearer ${token}` },
|
})).json()
|
}
|
|
async function api(page: Page, path: string, method = 'GET', data?: unknown) {
|
const body = await request(page, path, method, data)
|
expect(body.code, `${path}: ${body.msg}`).toBe(200)
|
return body
|
}
|
|
function payload(kind: Kind, name: string) {
|
const settings = kind === 'compare'
|
? { metrics: ['voltage', 'current', 'temperature'], seriesColors: colors, batteryTemplateFields: ['F'], specialFields: [], detailFieldValues: {} }
|
: kind === 'formation'
|
? { fields: [{ key: 'chargeCapacity', scope: 'cycle', kind: 'metric', displayName: '充电容量', unit: 'mAh' }], values: {}, chargeStepSelections: {}, metrics: ['voltage', 'current', 'temperature'], seriesColors: {} }
|
: { batteryFields: [], specialFields: [], detailFieldValues: {}, metrics: ['finalVoltage1', 'energyEfficiency'], seriesColors: colors }
|
return { name, description: 'Ticket13 隔离受控来源', ...(kind === 'compare' ? { experimentName: '' } : {}), settings,
|
selections: kind === 'compare' ? compareSelections : (kind === 'formation' ? sources : [9301, 9303]).map(id => selection(id)) }
|
}
|
|
// 仅向本次临时库导入确定的完成态事实;不连接设备,不以此证明真实协议兼容。
|
function seed() {
|
stack.executeIsolatedSql(`
|
INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9301,'连续验收组','验收型号'),(9304,'异组同名','验收型号');
|
INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value)
|
SELECT 9301,field_id,'12.5' FROM t_battery_group_field WHERE field_name='F';
|
INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9301,'化成');
|
INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,started_at_utc)
|
VALUES ${[...sources, 9305].map((id, i) => `(${id},'连续来源${i + 1}',1,1,9301,'隔离受控导入','${id === 9305 ? 'WAITING' : 'COMPLETED'}','${id === 9305 ? 'NOT_STARTED' : 'ENDED'}','2026-09-0${i + 1} 00:00:00')`).join(',')};
|
INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
|
VALUES ${[...sources, 9305].map(id => `(${id},${id},${id === 9304 ? 9304 : 9301},'同名电池',${id})`).join(',')},
|
(9401,9301,9301,'第二电池',9401),(9402,9302,9301,'第二电池',9402),(9403,9303,9301,'第二电池',9403);
|
`)
|
for (const battery of [...sources, 9401, 9402, 9403]) {
|
const id = battery >= 9401 ? battery - 100 : battery
|
const cycles = id === 9302 ? [5, 7] : id === 9303 ? [1, 3, 5] : [1, 3]
|
stack.executeIsolatedSql(`
|
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,voltage_v,current_a,temperature,positive_potential,negative_potential,content_sha256)
|
VALUES ${cycles.flatMap(cycle => [0, 1, 2].map(point => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 10 + point}',27,1,1,${battery},${cycle * 10 + point},'2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:10.123','2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:00.123','2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:11.123',${cycle},1,'实际充电',3.7,${point === 0 ? '-8.1651' : point === 1 ? 'NULL' : '0'},0,0.1,-0.2,UNHEX(SHA2('${battery}:${cycle}:${point}',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 ${cycles.flatMap(cycle => [1, 2].map(step => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 10 + step}','${cycle * 10 + step}',${cycle},${step},'${step === 1 ? '实际充电' : '实际放电'}','${step === 1 ? 'cc' : 'dc'}','2026-09-01 0${cycle}:0${step}:00.123','2026-09-01 0${cycle}:0${step}:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',${cycle === 3 && step === 2 ? 'NULL' : step === 1 ? '4.2' : '0'},UNHEX(SHA2('${battery}:${cycle}:${step}',256)))`)).join(',')};
|
INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,charge_capacity_ah,discharge_capacity_ah,charge_energy_wh,discharge_energy_wh,content_sha256)
|
VALUES ${cycles.map(cycle => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle}','${cycle}',${cycle},'2026-09-01 0${cycle}:03:00.123','2026-09-01 0${cycle}:03:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',8.5122,0,${cycle === 3 ? '0' : '0.1361'},0.1694,UNHEX(SHA2('${battery}:${cycle}',256)))`).join(',')};
|
`)
|
}
|
}
|
|
test('同一环境连续保存三类报表,三子页修订、失败回滚、重启及删源均保持版本独立', async ({ page }) => {
|
await login(page)
|
const saved: Partial<Record<Kind, any>> = {}
|
for (const kind of kinds) {
|
const candidates = (await api(page, `${kind}-report/sources`)).data
|
expect(candidates.some((row: any) => row.selection.testId === 9305)).toBe(false)
|
const draft = payload(kind, `连续验收-${kind}`)
|
await api(page, `${kind}-report/preview`, 'POST', draft.selections)
|
saved[kind] = (await api(page, `${kind}-report`, 'POST', draft)).data
|
expect(saved[kind].versionNo).toBe(1)
|
}
|
expect(saved.compare.items).toHaveLength(4)
|
expect(saved.compare.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('12.5')
|
expect(saved.compare.items[0].zeroTime).toBe(Date.parse('2026-09-01T01:00:00.123Z'))
|
expect(saved.formation.cycleNos).toEqual([1, 3, 5, 7])
|
expect(saved.formation.items[0].cycleNos).toEqual([1, 3])
|
expect(saved.splice.segments[0].statistics['1'].energyEfficiency).toBeCloseTo(124.467303453, 6)
|
|
for (const [kind, endpoint, field, value] of [
|
['REALTIME', 'records', 'current', -2], ['STEP', 'steps', 'endVoltage', 5.111], ['CYCLE', 'cycles', 'chargeCapacity', 9.7178],
|
] as const) {
|
const rows = (await api(page, `test/9301/data/${endpoint}`)).data
|
const row = rows.find((item: any) => item.cycleNo === 1 && item.batteryNo === '同名电池' && (kind !== 'STEP' || item.stepNo === 2))
|
expect(row).toBeTruthy()
|
await api(page, 'test/9301/console-data/actions', 'POST', {
|
dataType: kind, operationType: 'EDIT', scopeSummary: `${kind} 连续验收修订`, reason: '核对三类报表版本独立',
|
identitySummary: JSON.stringify([row.stableIdentity]),
|
overrides: [{ stableIdentity: row.stableIdentity, fieldValues: JSON.stringify({ [field]: value }) }],
|
})
|
expect((await api(page, `test/9301/data/${endpoint}`)).data.find((item: any) => item.stableIdentity === row.stableIdentity)[field]).toBe(value)
|
}
|
stack.executeIsolatedSql("UPDATE t_battery_group_relation_field SET field_value='25' WHERE group_id=9301")
|
const refreshed: Partial<Record<Kind, any>> = {}
|
for (const kind of kinds) {
|
const first = saved[kind]
|
const path = `${kind}-report/${first.id}`
|
expect((await api(page, path)).data).toEqual(first)
|
const edit = { ...payload(kind, `连续编辑-${kind}`), selections: undefined, baseVersionNo: 1, changeSummary: '仅调整名称' }
|
// 约束作用于真实写库过程,验证版本插入后失败会回滚,不模拟成功响应。
|
stack.executeIsolatedSql(`ALTER TABLE t_${kind}_report ADD CONSTRAINT ticket13_save_failure CHECK (report_name <> 'failure')`)
|
try { expect((await request(page, `${path}/versions`, 'POST', { ...edit, name: 'failure' })).code).not.toBe(200) }
|
finally { stack.executeIsolatedSql(`ALTER TABLE t_${kind}_report DROP CONSTRAINT ticket13_save_failure`) }
|
expect((await api(page, `${path}/versions`)).data).toHaveLength(1)
|
expect((await api(page, path)).data).toEqual(first)
|
const edited = (await api(page, `${path}/versions`, 'POST', edit)).data
|
expect(kind === 'splice' ? edited.segments : edited.items).toEqual(kind === 'splice' ? first.segments : first.items)
|
refreshed[kind] = (await api(page, `${path}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动读取三子页修订' })).data
|
expect((await api(page, `${path}/versions/1`)).data).toEqual(first)
|
for (const other of kinds.filter(item => item !== kind)) {
|
expect((await api(page, `${other}-report/${saved[other].id}/versions/1`)).data).toEqual(saved[other])
|
}
|
}
|
expect(refreshed.compare.items[0].records.some((row: any) => row.current === -2)).toBe(true)
|
expect(refreshed.compare.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('25')
|
expect((await api(page, `compare-report/${saved.compare.id}/versions/1`)).data.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('12.5')
|
expect(refreshed.formation.items[0].cycles.find((row: any) => row.cycleNo === 1).chargeCapacity).toBe(9.7178)
|
expect(refreshed.splice.segments[0].statistics['1'].finalVoltage1).toBe(5.111)
|
await stack.restartBackend()
|
await login(page)
|
const errors: string[] = []
|
page.on('pageerror', error => errors.push(error.message))
|
for (const kind of kinds) {
|
expect((await api(page, `${kind}-report/${saved[kind].id}`)).data).toEqual(refreshed[kind])
|
await page.goto(`/report-${kind}/${saved[kind].id}/edit?mode=view`)
|
await page.reload()
|
if (kind === 'splice') await expect(page.locator('.splice-snapshot h1')).toHaveText(`连续编辑-${kind}`)
|
else await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue(`连续编辑-${kind}`)
|
await expect(page.getByRole('button', { name: '历史版本', exact: true })).toBeEnabled()
|
}
|
const logsBefore = stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log')
|
await api(page, 'test/9301', 'DELETE')
|
expect(stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log')).toBe(logsBefore)
|
for (const table of ['t_test', 't_test_battery', 't_test_realtime_record', 't_test_step_record', 't_test_cycle_record', 't_test_data_revision_action']) {
|
expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=9301`)).toBe('0')
|
}
|
await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
|
for (const kind of kinds) {
|
const path = `${kind}-report/${saved[kind].id}`
|
expect((await api(page, `${path}/versions/1/print`)).data).toEqual(saved[kind])
|
expect((await request(page, `${path}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '已删除来源' })).code).not.toBe(200)
|
const edited = (await api(page, `${path}/versions`, 'POST', {
|
...payload(kind, `删源编辑-${kind}`), selections: undefined, baseVersionNo: 3, changeSummary: '删源后保留快照',
|
})).data
|
expect(kind === 'splice' ? edited.segments : edited.items).toEqual(kind === 'splice' ? refreshed[kind].segments : refreshed[kind].items)
|
await page.goto(`/report-${kind}/${saved[kind].id}/edit?mode=view&version=1`)
|
for (const width of [1440, 390]) {
|
await page.setViewportSize({ width, height: 900 })
|
await expect(page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })).toBeVisible()
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
await page.screenshot({ path: `tests/e2e/.artifacts/ticket13-${kind}-${width}.png`, fullPage: true })
|
}
|
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('body')).toContainText(`连续验收-${kind}`)
|
await popup.pdf({ path: `tests/e2e/.artifacts/ticket13-${kind}.pdf`, preferCSSPageSize: true, printBackground: true })
|
await popup.close()
|
expect((await api(page, `${path}/versions`)).data).toHaveLength(4)
|
await page.evaluate(() => { window.open = () => null })
|
await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
|
await expect(page.getByText('打印窗口打开失败,请允许浏览器弹出窗口后重试', { exact: true })).toBeVisible()
|
}
|
const retainedSource = (await api(page, 'test/9303/data/cycles')).data
|
for (const kind of kinds) {
|
const path = `${kind}-report/${saved[kind].id}`
|
await api(page, path, 'DELETE')
|
expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_${kind}_report_version WHERE report_id=${saved[kind].id}`)).toBe('0')
|
expect((await request(page, `${path}/versions/1`)).code).not.toBe(200)
|
const log = JSON.parse(stack.queryDatabase(`SELECT oper_param FROM sys_oper_log WHERE oper_url='/system/${path}' AND business_type=3`))
|
expect(Object.keys(log).sort()).toEqual(['operatorId', 'reportId', 'reportName'])
|
expect(log.reportId).toBe(saved[kind].id)
|
expect((await api(page, 'test/9303/data/cycles')).data).toEqual(retainedSource)
|
for (const other of kinds.slice(kinds.indexOf(kind) + 1)) {
|
expect((await api(page, `${other}-report/${saved[other].id}/versions/1`)).data).toEqual(saved[other])
|
}
|
}
|
expect(errors).toEqual([])
|
})
|
|
test('拼接前置行为:共同多电池、跳号和末工步缺失', async ({ page }) => {
|
await login(page)
|
// 后段从循环5开始,必须平移到前段末循环5之后;不额外假设首段的归零规则。
|
const single = [selection(9303), selection(9302)]
|
const preview = (await api(page, 'splice-report/preview', 'POST', single)).data
|
const nextSegment = preview.segments[1]
|
expect.soft(nextSegment.originalCycleNos.map((no: number) => no + nextSegment.offset), '后段应紧接前段且保留段内跳号').toEqual([6, 8])
|
expect.soft(preview.segments[0].statistics['3'].finalVoltage1 ?? null, '最后实际工步缺失,不能回退到 4.2 V').toBeNull()
|
const multiple = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9302, 9402), selection(9303), selection(9303, 9403)])
|
expect.soft(multiple.code, `共同多电池:${multiple.msg}`).toBe(200)
|
const mismatch = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9304)])
|
expect(mismatch.code).not.toBe(200)
|
for (const invalid of [
|
[selection(9302), selection(9302, 9402)],
|
[selection(9302), selection(9303), selection(9303, 9403)],
|
[selection(9302), selection(9305)],
|
[selection(9302, 9403), selection(9303)],
|
]) expect((await request(page, 'splice-report/preview', 'POST', invalid)).code).not.toBe(200)
|
try {
|
for (const value of ['-0.1361', 'NULL']) {
|
stack.executeIsolatedSql(`UPDATE t_test_cycle_record SET charge_energy_wh=${value} WHERE test_id=9302 AND cycle_no=5`)
|
const result = (await api(page, 'splice-report/preview', 'POST', single)).data
|
expect(result.segments[1].statistics['5'].energyEfficiency ?? null).toBeNull()
|
}
|
} finally { stack.executeIsolatedSql('UPDATE t_test_cycle_record SET charge_energy_wh=0.1361 WHERE test_id=9302 AND cycle_no=5') }
|
})
|
|
test('删除日志写入失败必须保留整份拼接报表和所有版本', async ({ page }) => {
|
await login(page)
|
const single = [selection(9302), selection(9303)]
|
const created = (await api(page, 'splice-report', 'POST', { ...payload('splice', '删除失败保留完整报表'), selections: single })).data
|
const edited = (await api(page, `splice-report/${created.id}/versions`, 'POST', {
|
name: '删除失败保留两个版本', description: '', settings: created.settings, baseVersionNo: 1, changeSummary: '追加历史用于核对完整回滚',
|
})).data
|
stack.executeIsolatedSql(`ALTER TABLE sys_oper_log ADD CONSTRAINT ticket13_delete_failure CHECK (oper_url <> '/system/splice-report/${created.id}')`)
|
try {
|
expect((await request(page, `splice-report/${created.id}`, 'DELETE')).code).not.toBe(200)
|
const reopened = await request(page, `splice-report/${created.id}`)
|
expect(reopened.code, '日志插入失败必须回滚整份删除').toBe(200)
|
expect(reopened.data).toEqual(edited)
|
expect((await api(page, `splice-report/${created.id}/versions/1`)).data).toEqual(created)
|
expect((await api(page, `splice-report/${created.id}/versions`)).data).toHaveLength(2)
|
await page.goto('/report-splice')
|
const row = page.locator('tbody tr').filter({ hasText: edited.name })
|
await row.getByRole('button', { name: '删除', exact: true }).click()
|
const rejected = page.waitForResponse(response => response.url().endsWith(`/system/splice-report/${created.id}`) && response.request().method() === 'DELETE')
|
await page.getByRole('dialog', { name: '确认删除拼接报表', exact: true }).getByRole('button', { name: '删除', exact: true }).click()
|
expect((await (await rejected).json()).code).not.toBe(200)
|
await expect(page.locator('.el-message--error')).toBeVisible()
|
await expect(page.getByText('拼接报表已删除', { exact: true })).toHaveCount(0)
|
await expect(row).toBeVisible()
|
} finally { stack.executeIsolatedSql('ALTER TABLE sys_oper_log DROP CONSTRAINT ticket13_delete_failure') }
|
await api(page, `splice-report/${created.id}`, 'DELETE')
|
expect((await request(page, `splice-report/${created.id}`)).code).not.toBe(200)
|
expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_splice_report_version WHERE report_id=${created.id}`)).toBe('0')
|
expect(stack.queryDatabase(`SELECT COUNT(*) FROM sys_oper_log WHERE oper_url='/system/splice-report/${created.id}' AND business_type=3`)).toBe('1')
|
})
|
|
test('拼接模板仅保存所属类型字段结构,业务字段和人工指标随版本独立保存', async ({ page }) => {
|
await login(page)
|
const fields = [{ name: 'F', category: 'battery', displayName: '业务F', unit: '' }, { name: '说明项', category: 'special', displayName: '人工说明', unit: '' }]
|
const template = (await api(page, 'splice-report/templates', 'POST', { name: '拼接专用字段', fields })).data
|
const templates = (await api(page, 'splice-report/templates')).data
|
const stored = templates.find((item: any) => item.id === template)
|
expect(stored.type).toBe('splice')
|
expect(stored.fields[0].fieldDefinitionId).toBeGreaterThan(0)
|
for (const kind of ['compare', 'formation']) expect((await api(page, `${kind}-report/templates`)).data.some((item: any) => item.name === stored.name)).toBe(false)
|
expect((await request(page, 'splice-report/templates', 'POST', { name: '夹带来源', fields, selections: [selection(9302)] })).code).not.toBe(200)
|
expect((await request(page, 'splice-report/templates', 'POST', { name: '夹带值', fields: [{ ...fields[1], value: '不允许' }] })).code).not.toBe(200)
|
const selections = [selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)]
|
const draft = { ...payload('splice', '模板与人工统计'), selections, settings: { ...payload('splice', '').settings, fieldStructure: stored.fields, statisticValues: {} } }
|
const first = (await api(page, 'splice-report', 'POST', draft)).data
|
expect(first.segments[0].businessFields.map((field: any) => field.fieldName)).toEqual(['F'])
|
expect(first.cycleNos).toEqual([1, 3, 5, 6, 8])
|
expect(first.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1])
|
const original = (await api(page, 'test/9303/data/steps')).data
|
const second = (await api(page, `splice-report/${first.id}/versions`, 'POST', { name: first.name, description: '', baseVersionNo: 1, changeSummary: '人工调整', settings: { ...first.settings, statisticValues: { '9303|9303|3|finalVoltage1': '-1.25' } } })).data
|
expect(second.segments).toEqual(first.segments)
|
expect(second.settings.statisticValues['9303|9303|3|finalVoltage1']).toBe('-1.25')
|
expect((await api(page, 'test/9303/data/steps')).data).toEqual(original)
|
await api(page, `splice-report/templates/${template}`, 'PUT', { name: stored.name, fields: [fields[1]] })
|
expect((await api(page, `splice-report/${first.id}/versions/1`)).data).toEqual(first)
|
expect((await request(page, `splice-report/${first.id}/versions`, 'POST', { name: first.name, baseVersionNo: 2, changeSummary: '伪造指标', settings: { ...first.settings, statisticValues: { '999|999|3|finalVoltage1': '7' } } })).code).not.toBe(200)
|
})
|
|
test('浏览器从真实来源新建多电池拼接,重排、模板、人工调整、重开及打印', async ({ page }) => {
|
await login(page)
|
const errors: string[] = []
|
page.on('pageerror', error => errors.push(error.message))
|
await page.goto('/report-splice/new')
|
await page.getByPlaceholder('请输入拼接报表名称').fill('浏览器多电池拼接')
|
for (const name of ['连续来源3', '连续来源2']) {
|
await page.locator('.panel-actions').getByRole('button', { name: '添加拼接', exact: true }).click()
|
const dialog = page.getByRole('dialog', { name: '添加拼接', exact: true })
|
await dialog.getByPlaceholder('输入测试名称或ID搜索').fill(name)
|
await dialog.getByRole('combobox').last().click()
|
await page.getByRole('option', { name: new RegExp(name) }).click()
|
await dialog.getByRole('button', { name: '添加拼接', exact: true }).click()
|
await expect(dialog).not.toBeVisible()
|
}
|
await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源2')
|
await page.getByRole('button', { name: '下移测试', exact: true }).first().click()
|
await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源3')
|
await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10)
|
await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
|
await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5)
|
await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
|
await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10)
|
await expect(page.locator('.splice-snapshot')).not.toContainText('SP2')
|
await expect(page.locator('.splice-chart')).toHaveCount(2)
|
await page.getByRole('button', { name: '字段设置', exact: true }).click()
|
const fieldDialog = page.getByRole('dialog', { name: '字段设置', exact: true })
|
const fRow = fieldDialog.locator('tbody tr').filter({ has: page.getByRole('checkbox', { name: '选择F', exact: true }) })
|
await fRow.locator('.el-checkbox').click()
|
await fRow.getByRole('textbox', { name: '展示名称', exact: true }).fill('冻结业务F')
|
await fieldDialog.getByRole('button', { name: '确认', exact: true }).click()
|
await expect(page.locator('.detail-table tbody tr').first().locator('input').first()).toHaveValue(/^(12.5|25)$/)
|
await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
|
await page.getByRole('button', { name: '保存为模板', exact: true }).click()
|
const saveTemplate = page.getByRole('dialog', { name: '保存拼接字段模板', exact: true })
|
await saveTemplate.getByRole('textbox').fill('浏览器拼接模板')
|
await saveTemplate.getByRole('button', { name: '保存', exact: true }).click()
|
await expect(page.getByText('字段模板已保存', { exact: true })).toBeVisible()
|
await page.getByRole('dialog', { name: '选择拼接字段模板', exact: true }).getByRole('button', { name: '应用模板', exact: true }).click()
|
await page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true }).fill('-1.25')
|
await page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true }).fill('9')
|
await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
|
await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5)
|
await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
|
await expect(page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true })).toHaveValue('')
|
await expect(page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true })).toHaveValue('-1.25')
|
for (const width of [1440, 390]) {
|
await page.setViewportSize({ width, height: 900 })
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
await page.screenshot({ path: `tests/e2e/.artifacts/ticket11-new-${width}.png`, fullPage: true })
|
}
|
const response = page.waitForResponse(response => response.url().endsWith('/system/splice-report') && response.request().method() === 'POST')
|
await page.getByRole('button', { name: '保存报表', exact: true }).click()
|
const saved = (await (await response).json()).data
|
expect(saved.selections).toEqual([selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)])
|
expect(saved.settings.fieldStructure[0].displayName).toBe('冻结业务F')
|
await expect(page).toHaveURL(/\/report-splice$/)
|
await page.getByPlaceholder('输入拼接名称或ID').fill(saved.name)
|
await page.getByRole('button', { name: '查询', exact: true }).click()
|
const reportRow = page.locator('tbody tr').filter({ hasText: saved.name })
|
await expect(reportRow.locator('td').nth(2)).toHaveText(/^\d{4}-\d{2}-\d{2} /)
|
await reportRow.getByRole('button', { name: '查看报表', exact: true }).click()
|
await page.reload()
|
await expect(page.locator('.splice-snapshot')).toContainText('-1.25')
|
await expect(page.locator('.splice-snapshot')).toContainText('计算原值:缺失')
|
await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
|
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')
|
expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText())
|
await popup.pdf({ path: 'tests/e2e/.artifacts/ticket11-multi-battery.pdf', preferCSSPageSize: true, printBackground: true })
|
await popup.close()
|
await page.goto(`/report-splice/${saved.id}/edit`)
|
await page.getByRole('button', { name: '按最新来源生成新版本', exact: true }).click()
|
const refreshDialog = page.getByRole('dialog', { name: '按最新来源生成新版本', exact: true })
|
await refreshDialog.getByRole('textbox').fill('浏览器主动更新')
|
await refreshDialog.getByRole('button', { name: '生成新版本', exact: true }).click()
|
await expect(page.getByText('已按最新来源生成新版本', { exact: true })).toBeVisible()
|
expect((await api(page, `splice-report/${saved.id}/versions/1`)).data).toEqual(saved)
|
expect(errors).toEqual([])
|
})
|
|
test('各类五动作权限可配置且不能跨类别使用,查看用户只能查看跨创建人报表', async ({ page }) => {
|
await login(page)
|
const modules = (await api(page, 'menu/permissionList')).data.flatMap((module: any) => module.pages)
|
const names = { compare: '对比报表', formation: '化成报表', splice: '拼接报表' }
|
const saved: Partial<Record<Kind, any>> = {}
|
for (const kind of kinds) {
|
saved[kind] = (await api(page, `${kind}-report`, 'POST', {
|
...payload(kind, `权限验收-${kind}`),
|
selections: kind === 'compare' ? compareSelections.filter(item => item.testId === 9303) : [selection(9302), selection(9303)],
|
})).data
|
const permissions = modules.find((module: any) => module.pageName === names[kind]).permissions
|
for (const action of ['查看', '新增', '编辑', '删除', '导出']) {
|
expect.soft(permissions.filter((item: any) => item.menuName === action), `${names[kind]} ${action} 必须可通过正式权限页配置`).toHaveLength(1)
|
}
|
const username = `ticket13_${kind}`
|
await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.filter((item: any) => item.menuName === '查看').map((item: any) => item.menuId) })
|
const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
|
await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
|
}
|
for (const kind of kinds) {
|
await login(page, `ticket13_${kind}`)
|
await api(page, `${kind}-report/list`)
|
expect((await api(page, `${kind}-report/${saved[kind].id}`)).data).toEqual(saved[kind])
|
for (const target of kinds) {
|
if (target !== kind) expect((await request(page, `${target}-report/list`)).code).toBe(403)
|
for (const [path, method, data] of [
|
[`${target}-report`, 'POST', payload(target, '无权限新建')],
|
[`${target}-report/1/versions`, 'POST', { ...payload(target, '无权限编辑'), selections: undefined, baseVersionNo: 1, changeSummary: '拒绝' }],
|
[`${target}-report/1`, 'DELETE', undefined],
|
[`${target}-report/1/versions/1/print`, 'GET', undefined],
|
] as const) expect((await request(page, path, method, data)).code, path).toBe(403)
|
}
|
await page.goto(`/report-${kind}`)
|
await expect(page.getByRole('button', { name: /新建|新增/ })).toHaveCount(0)
|
await expect(page.locator('tbody').getByRole('button', { name: /^编辑(报表)?$/ })).toHaveCount(0)
|
await expect(page.locator('tbody').getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
|
}
|
for (const kind of kinds) {
|
await login(page)
|
const username = `t13_${kind}_actor`
|
const permissions = modules.find((module: any) => module.pageName === names[kind]).permissions
|
await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.map((item: any) => item.menuId) })
|
const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
|
await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
|
await login(page, username)
|
const first = saved[kind]
|
await api(page, `${kind}-report/list`)
|
await api(page, `${kind}-report/${first.id}`)
|
const edited = (await api(page, `${kind}-report/${first.id}/versions`, 'POST', {
|
...payload(kind, `跨创建人-${kind}`), settings: first.settings, selections: undefined, baseVersionNo: 1, changeSummary: '有权限的其他用户编辑',
|
})).data
|
expect(edited.operatorName).toBe(username)
|
expect((await api(page, `${kind}-report/${first.id}/versions/1/print`)).data).toEqual(first)
|
const own = (await api(page, `${kind}-report`, 'POST', {
|
...payload(kind, `授权创建-${kind}`),
|
selections: kind === 'compare' ? compareSelections.filter(item => item.testId === 9303) : [selection(9302), selection(9303)],
|
})).data
|
const templateFields = kind === 'formation'
|
? [{ key: '人工验收项', scope: 'detail', kind: 'manual', displayName: '人工验收项', unit: '' }]
|
: [{ name: '人工验收项', category: 'special', displayName: '人工验收项', unit: '' }]
|
const createdTemplate = await api(page, `${kind}-report/templates`, 'POST', { name: `类型隔离-${kind}`, fields: templateFields })
|
await api(page, `${kind}-report/templates/${createdTemplate.id ?? createdTemplate.data}`, 'PUT', { name: `类型隔离更新-${kind}`, fields: templateFields })
|
expect((await api(page, `${kind}-report/${first.id}/versions/1`)).data).toEqual(first)
|
for (const target of kinds.filter(value => value !== kind)) expect((await request(page, `${target}-report/templates`)).code).toBe(403)
|
await page.goto(`/report-${kind}`)
|
await expect(page.getByRole('button', { name: /新建|新增/ })).toBeVisible()
|
const row = page.locator('tbody tr').filter({ hasText: `跨创建人-${kind}` })
|
await expect(row.getByRole('button', { name: /^编辑(报表)?$/ })).toBeVisible()
|
await expect(row.getByRole('button', { name: '删除', exact: true })).toBeVisible()
|
await api(page, `${kind}-report/${first.id}`, 'DELETE')
|
await api(page, `${kind}-report/${own.id}`, 'DELETE')
|
}
|
})
|
|
test('工步与项目图表仍使用真实持久化数据,筛选、显隐及详情重开正常', async ({ page }) => {
|
await login(page)
|
const before = (await api(page, 'test/9303/data/records')).data
|
await page.goto('/test-list/9303/console?tab=step-chart')
|
await expect(page.locator('.metric-chart canvas')).toHaveCount(5)
|
await page.locator('.detail-meta input').fill('Ticket13 工步详情回归')
|
await page.getByRole('button', { name: '保存详情', exact: true }).click()
|
await expect(page.getByText('测试详情已保存', { exact: true })).toBeVisible()
|
await page.reload()
|
await expect(page.locator('.detail-meta input')).toHaveValue('Ticket13 工步详情回归')
|
await page.locator('.data-select-panel .step-select').first().locator('.el-select').click()
|
await page.getByRole('option', { name: '循环 3', exact: true }).click()
|
await expect(page.locator('.battery-range-row .range-label').first()).toContainText('循环 3')
|
await page.locator('.metric-switches label').filter({ hasText: '温度' }).click()
|
await expect(page.locator('.metric-chart canvas')).toHaveCount(4)
|
await page.locator('.metric-switches label').filter({ hasText: '温度' }).click()
|
await expect(page.locator('.metric-chart canvas')).toHaveCount(5)
|
await expect.poll(async () => page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
|
const context = canvas.getContext('2d')!
|
const data = context.getImageData(0, 0, canvas.width, canvas.height).data
|
let colored = 0
|
for (let i = 0; i < data.length; i += 4) if (data[i + 3]! > 0 && Math.max(data[i]!, data[i + 1]!, data[i + 2]!) - Math.min(data[i]!, data[i + 1]!, data[i + 2]!) > 50) colored++
|
return colored
|
})).toBeGreaterThan(50)
|
await page.screenshot({ path: 'tests/e2e/.artifacts/ticket13-step-chart-regression.png', fullPage: true })
|
await page.getByRole('button', { name: '项目图表', exact: true }).click()
|
await expect(page.locator('.project-chart canvas')).toHaveCount(3)
|
expect((await api(page, 'test/9303/data/records')).data).toEqual(before)
|
})
|
|
test('多电池循环覆盖不同,图上来源分界仍位于该测试最早拼接循环', async ({ page }) => {
|
await login(page)
|
for (const table of ['t_test_realtime_record', 't_test_step_record', 't_test_cycle_record']) {
|
stack.executeIsolatedSql(`DELETE FROM ${table} WHERE (test_battery_id=9403 AND cycle_no=1) OR (test_battery_id=9402 AND cycle_no=5)`)
|
}
|
const saved = (await api(page, 'splice-report', 'POST', {
|
...payload('splice', '不同循环覆盖的来源分界'),
|
selections: [selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)],
|
})).data
|
expect(saved.segments.map((segment: any) => segment.originalCycleNos)).toEqual([[1, 3, 5], [3, 5], [5, 7], [7]])
|
expect(saved.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1])
|
await page.goto(`/report-splice/${saved.id}/edit?mode=view`)
|
const lines = page.locator('.splice-chart').first().locator('svg path[stroke="#82909b"]')
|
await expect(lines).toHaveCount(2)
|
const positions = await lines.evaluateAll(lines => lines.map(line => (line as SVGPathElement).getPointAtLength(0).x))
|
// 固定900像素图中循环1、6位于122和622附近;允许描边对齐的半像素偏移。
|
expect(Math.abs(positions[0]! - 122)).toBeLessThanOrEqual(1)
|
expect(Math.abs(positions[1]! - 622)).toBeLessThanOrEqual(1)
|
await page.screenshot({ path: 'tests/e2e/.artifacts/ticket11-asymmetric-cycles.png', fullPage: true })
|
})
|