| New file |
| | |
| | | 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].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.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) |
| | | // 独立测试使用未被连续演示删除的来源,并覆盖非 1 起始循环。 |
| | | const single = [selection(9302), selection(9303)] |
| | | const preview = (await api(page, 'splice-report/preview', 'POST', single)).data |
| | | expect.soft(preview.cycleNos, '应从 1 接续并保留段内跳号').toEqual([1, 3, 4, 6, 8]) |
| | | expect.soft(preview.segments[1].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) |
| | | }) |
| | | |
| | | 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 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', { baseVersionNo: 1, name: '无权限编辑', settings: {}, 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) |
| | | } |
| | | }) |