import { expect, test, type Page } from '@playwright/test'
|
import { Ticket04Stack } from './support/ticket04-stack'
|
|
const stack = new Ticket04Stack()
|
test.beforeAll(async () => stack.start())
|
test.afterAll(async () => stack.stop())
|
|
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 response = await page.request.fetch(`${stack.backendUrl}/system/${path}`, {
|
method, data, headers: { Authorization: `Bearer ${token}` },
|
})
|
const body = await response.json()
|
expect(body.code, `${path}: ${body.msg}`).toBe(code)
|
return body
|
}
|
|
test('快照编辑追加版本,主动更新才纳入修订,历史与来源删除独立', async ({ page }) => {
|
const pageErrors: string[] = []
|
page.on('pageerror', error => pageErrors.push(error.message))
|
stack.executeIsolatedSql(`
|
INSERT INTO t_battery_group(group_id,group_name,model) VALUES(8201,'版本验收组','型号');
|
INSERT INTO t_battery_group_field(field_id,field_name,field_type) VALUES(8201,'质量','number');
|
INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) VALUES(8201,8201,'88');
|
INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(8201,'版本验收类型');
|
INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
|
VALUES(8201,'版本来源测试',1,1,8201,'隔离验收','COMPLETED','ENDED');
|
INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(8201,8201,8201,'BAT-V',8201);
|
INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
|
VALUES(8201,'version-fixture','version-fixture','验收检测柜',27,1);
|
INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(8201,'version-fixture',8201,27,1,1,1);
|
INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(8201,8201,8201,8201);
|
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,content_sha256)
|
VALUES(8201,8201,8201,'version-fixture','version:27:1:1:1','1',27,1,1,1,1,'2026-09-01 00:00:10.123','2026-09-01 00:00:00.123','2026-09-01 00:00:11.123',1,1,'充电',3.7,-2,0,UNHEX(SHA2('v1',256))),
|
(8201,8201,8201,'version-fixture','version:27:1:1:1','2',27,1,1,1,2,'2026-09-01 00:01:10.123','2026-09-01 00:01:00.123','2026-09-01 00:01:11.123',1,1,'充电',3.8,NULL,NULL,UNHEX(SHA2('v2',256))),
|
(8201,8201,8201,'version-fixture','version:27:1:1:1','3',27,1,1,1,3,'2026-09-01 00:02:10.123','2026-09-01 00:02:00.123','2026-09-01 00:02:11.123',1,2,'放电',3.6,1,25,UNHEX(SHA2('v3',256)));
|
`)
|
await login(page)
|
const selections = [1, 2].map(stepNo => ({ testId: 8201, testBatteryId: 8201, cycleNo: 1, stepNo, logicalNo: null }))
|
const payload = {
|
name: '版本验收V1', description: '原说明', experimentName: '版本实验', selections,
|
settings: { metrics: ['voltage', 'current', 'temperature'], seriesColors: ['#123456', '#109854'],
|
batteryTemplateFields: ['质量'], specialFields: ['人工指标'], detailFieldValues: { 人工指标: { '8201|8201|1|1|': '12' } } },
|
}
|
const v1 = (await api(page, 'compare-report', 'POST', payload)).data
|
const firstIdentity = v1.items[0].records.find((row: any) => row.recordNo === '1').stableIdentity
|
await api(page, 'test/8201/console-data/actions', 'POST', {
|
dataType: 'REALTIME', operationType: 'EDIT', scopeSummary: '电流修订', reason: '验证版本隔离',
|
identitySummary: JSON.stringify([firstIdentity]),
|
overrides: [{ stableIdentity: firstIdentity, fieldValues: JSON.stringify({ current: 66 }) }],
|
})
|
const edit = { name: '版本验收V2', description: '新说明', experimentName: '版本实验',
|
settings: payload.settings, baseVersionNo: 1, changeSummary: '普通编辑说明' }
|
const v2 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', edit)).data
|
expect(v2.versionNo).toBe(2)
|
expect(v2.items).toEqual(v1.items)
|
expect((await api(page, `compare-report/${v1.id}/versions/1`)).data).toEqual(v1)
|
expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v2)
|
const history = (await api(page, `compare-report/${v1.id}/versions`)).data
|
expect(history.map((version: any) => version.versionNo)).toEqual([2, 1])
|
expect(history[0]).toMatchObject({ operatorName: 'admin', changeSummary: '普通编辑说明' })
|
expect(history[0].createdAt).toBeGreaterThan(0)
|
const v3 = (await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '纳入电流修订' })).data
|
expect(v3.versionNo).toBe(3)
|
expect(v3.items[0].records.find((row: any) => row.recordNo === '1').current).toBe(66)
|
expect(v3.settings.detailFieldValues).toEqual(v2.settings.detailFieldValues)
|
expect((await api(page, `compare-report/${v1.id}/versions/2`)).data).toEqual(v2)
|
|
await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
|
await page.getByRole('button', { name: '编辑报表', exact: true }).click()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V2')
|
await page.getByPlaceholder('请输入报表名称').fill('浏览器编辑V4')
|
await page.getByPlaceholder('请输入修改说明').fill('调整人工表格与显隐')
|
await page.locator('.field-table tbody tr').filter({ hasText: '人工指标' }).locator('input').first().fill('99')
|
await page.locator('.metric-switches label').filter({ hasText: '电流' }).locator('.el-switch__core').click()
|
await page.route('**/dev-api/system/compare-report/*/versions', async route => {
|
await route.fulfill({ json: { code: 500, msg: '版本保存故障' } })
|
}, { times: 1 })
|
await page.getByRole('button', { name: '保存新版本', exact: true }).click()
|
await expect(page.getByText('版本保存故障', { exact: true })).toBeVisible()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('浏览器编辑V4')
|
await page.getByRole('button', { name: '保存新版本', exact: true }).click()
|
await expect(page).toHaveURL(/mode=view$/)
|
const v4 = (await api(page, `compare-report/${v1.id}`)).data
|
expect(v4.versionNo).toBe(4)
|
expect(v4.items).toEqual(v3.items)
|
expect(v4.settings.detailFieldValues['人工指标']['8201|8201|1|1|']).toBe('99')
|
expect(v4.settings.metrics).not.toContain('current')
|
const beforeHistory = (await api(page, `compare-report/${v1.id}/versions`)).data
|
const nextEdit = { ...edit, baseVersionNo: 4 }
|
for (const invalid of [
|
{ ...nextEdit, items: [{ records: [{ current: 999 }] }] },
|
{ ...nextEdit, selections },
|
{ ...nextEdit, settings: { ...payload.settings, records: [] } },
|
{ ...nextEdit, baseVersionNo: 1 },
|
{ ...nextEdit, changeSummary: '' },
|
]) await api(page, `compare-report/${v1.id}/versions`, 'POST', invalid, 500)
|
await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 4, changeSummary: '伪造', items: [] }, 500)
|
// 在版本插入后的名称更新处拒绝写入,证明新版本也随事务回滚。
|
stack.executeIsolatedSql("ALTER TABLE t_compare_report ADD CONSTRAINT version_failure CHECK (report_name <> 'failure')")
|
await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, name: 'failure' }, 500)
|
stack.executeIsolatedSql('ALTER TABLE t_compare_report DROP CONSTRAINT version_failure')
|
expect((await api(page, `compare-report/${v1.id}/versions`)).data).toEqual(beforeHistory)
|
expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v4)
|
expect((await api(page, 'test/8201/data/records')).data.find((row: any) => row.stableIdentity === firstIdentity).current).toBe(66)
|
await page.getByRole('button', { name: '历史版本', exact: true }).click()
|
const dialog = page.getByRole('dialog', { name: '历史版本' })
|
await expect(dialog.locator('.el-table__body-wrapper tbody tr')).toHaveCount(4)
|
await dialog.locator('.el-table__body-wrapper tbody tr').filter({ hasText: '首次保存对比报表' }).getByRole('button', { name: '查看版本' }).click()
|
await expect(page).toHaveURL(/version=1/)
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V1')
|
await expect(page.getByRole('button', { name: '编辑报表', exact: true })).toHaveCount(0)
|
await expect(page.locator('.metric-chart canvas')).toHaveCount(3)
|
await page.reload()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('版本验收V1')
|
await page.getByRole('button', { name: '查看最新版本', exact: true }).click()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('浏览器编辑V4')
|
await page.getByRole('button', { name: '按最新来源生成版本', exact: true }).click()
|
await page.getByPlaceholder('请输入更新来源说明').fill('浏览器主动更新')
|
await page.getByRole('button', { name: '生成新版本', exact: true }).click()
|
await expect(page.getByText('已生成版本 5', { exact: true })).toBeVisible()
|
const v5 = (await api(page, `compare-report/${v1.id}`)).data
|
expect(v5.versionNo).toBe(5)
|
expect(v5.settings.detailFieldValues['人工指标']['8201|8201|1|1|']).toBe('99')
|
|
// 删除单条来源只影响主动更新;已存历史保留完整测量。
|
await api(page, 'test/8201/console-data/actions', 'POST', {
|
dataType: 'REALTIME', operationType: 'DELETE', scopeSummary: '删除第一条测量', reason: '验证版本独立',
|
identitySummary: JSON.stringify([firstIdentity]),
|
})
|
const v6 = (await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 5, changeSummary: '排除已删除记录' })).data
|
expect(v6.items[0].records).toHaveLength(1)
|
expect(v6.items[0].records[0].current ?? null).toBeNull()
|
expect(v6.items[0].zeroTime).toBe(Date.parse('2026-09-01T00:01:00.123Z'))
|
expect((await api(page, `compare-report/${v1.id}/versions/1`)).data).toEqual(v1)
|
await api(page, 'test/8201', 'DELETE')
|
const v7 = (await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, baseVersionNo: 6, name: '源删除后编辑' })).data
|
expect(v7.items).toEqual(v6.items)
|
await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 7, changeSummary: '已删除来源' }, 500)
|
expect((await api(page, `compare-report/${v1.id}`)).data).toEqual(v7)
|
expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report_version')).toBe('7')
|
expect(stack.queryDatabase('SELECT COUNT(*) FROM t_test WHERE test_id=8201')).toBe('0')
|
await stack.restartBackend()
|
await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
|
await login(page)
|
await page.goto('/report-compare')
|
await page.getByRole('button', { name: '修订记录', exact: true }).click()
|
await expect(page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr')).toHaveCount(7)
|
await page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr').first().getByRole('button', { name: '查看版本' }).click()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('源删除后编辑')
|
await page.getByRole('button', { name: '查看最新版本', exact: true }).click()
|
for (const width of [1440, 390]) {
|
await page.setViewportSize({ width, height: 900 })
|
await expect.poll(async () => (await page.locator('.metric-chart canvas').first().boundingBox())!.width).toBeGreaterThan(200)
|
await page.screenshot({ path: `tests/e2e/.artifacts/compare-version-${width}.png`, fullPage: true, animations: 'disabled' })
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
const pixels = await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
|
const data = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
|
let colored = 0
|
for (let i = 0; i < data.length; i += 4) if (data[i + 3] > 100 && Math.abs(data[i] - data[i + 2]) > 50) colored++
|
return colored
|
})
|
expect(pixels).toBeGreaterThan(10)
|
}
|
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.getByRole('button', { name: '历史版本', exact: true }).click()
|
await expect(page.getByRole('dialog', { name: '历史版本' }).locator('.el-table__body-wrapper tbody tr')).toHaveCount(7)
|
await page.screenshot({ path: 'tests/e2e/.artifacts/compare-version-history.png', animations: 'disabled' })
|
|
const menus = (await api(page, 'menu/permissionList')).data
|
const comparePage = menus.flatMap((module: any) => module.pages).find((item: any) => item.pageName === '对比报表')
|
expect(comparePage.permissions.map((item: any) => item.menuName)).toContain('编辑')
|
for (const [username, action] of [['version_reader', '查看'], ['version_editor', '编辑'], ['version_author', '新增'], ['version_denied', '']]) {
|
const menuIds = comparePage.permissions.filter((item: any) => item.menuName === action).map((item: any) => item.menuId)
|
await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds })
|
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 username of ['version_reader', 'version_editor', 'version_author', 'version_denied']) {
|
await page.evaluate(() => { localStorage.removeItem('battery_detection_auth_token'); localStorage.removeItem('battery_detection_auth_user') })
|
await login(page, username)
|
const canRead = username === 'version_reader'
|
const canEdit = username === 'version_editor'
|
await api(page, `compare-report/${v1.id}/versions`, 'GET', undefined, canRead ? 200 : 403)
|
await api(page, `compare-report/${v1.id}/versions/1`, 'GET', undefined, canRead ? 200 : 403)
|
await api(page, `compare-report/${v1.id}/editing`, 'GET', undefined, canEdit ? 200 : 403)
|
if (!canEdit) {
|
await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...nextEdit, baseVersionNo: 7 }, 403)
|
await api(page, `compare-report/${v1.id}/refresh-source`, 'POST', { baseVersionNo: 7, changeSummary: '无权限更新' }, 403)
|
} else {
|
// 只有编辑权限的另一用户也可直接编辑,保存后继续读取编辑基准。
|
await page.goto(`/report-compare/${v1.id}/edit?mode=edit`)
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('源删除后编辑')
|
await page.getByPlaceholder('请输入报表名称').fill('跨用户编辑V8')
|
await page.getByPlaceholder('请输入修改说明').fill('编辑权限跨创建人')
|
await page.getByRole('button', { name: '保存新版本', exact: true }).click()
|
await expect(page).toHaveURL(/mode=view$/)
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('跨用户编辑V8')
|
const v8 = (await api(page, `compare-report/${v1.id}/editing`)).data
|
expect(v8.versionNo).toBe(8)
|
expect(v8.operatorName).toBe('version_editor')
|
expect(v8.items).toEqual(v7.items)
|
await expect(page.getByRole('button', { name: '历史版本', exact: true })).toHaveCount(0)
|
await page.getByRole('button', { name: '编辑报表', exact: true }).click()
|
await page.getByPlaceholder('请输入报表名称').fill('取消的修改')
|
await page.getByRole('button', { name: '取消编辑', exact: true }).click()
|
await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue('跨用户编辑V8')
|
await page.getByRole('button', { name: '按最新来源生成版本', exact: true }).click()
|
await page.getByPlaceholder('请输入更新来源说明').fill('删除来源拒绝更新')
|
const rejectedRefresh = page.waitForResponse(response => response.url().endsWith('/refresh-source'))
|
await page.getByRole('button', { name: '生成新版本', exact: true }).click()
|
expect((await (await rejectedRefresh).json()).code).toBe(500)
|
expect((await api(page, `compare-report/${v1.id}/editing`)).data).toEqual(v8)
|
}
|
if (canRead) {
|
await page.goto(`/report-compare/${v1.id}/edit?mode=view`)
|
await expect(page.getByRole('button', { name: '历史版本', exact: true })).toBeVisible()
|
await expect(page.getByRole('button', { name: '编辑报表', exact: true })).toHaveCount(0)
|
await expect(page.getByRole('button', { name: '按最新来源生成版本', exact: true })).toHaveCount(0)
|
}
|
}
|
expect(pageErrors).toEqual([])
|
})
|