| | |
| | | : 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, |
| | | return { name, description: '本轮组合验收隔离来源', ...(kind === 'compare' ? { experimentName: '' } : {}), settings, |
| | | selections: kind === 'compare' ? compareSelections : (kind === 'formation' ? sources : [9301, 9303]).map(id => selection(id)) } |
| | | } |
| | | |
| | |
| | | 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(',')}; |
| | | VALUES ${cycles.flatMap(cycle => [1, 2, 3].flatMap(step => [0, 1, 2].map(point => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 100 + step * 10 + point}',27,1,1,${battery},${cycle * 100 + step * 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},${step},'实际工步${step}',${step === 3 ? '2.5' : '3.7'},${point === 0 ? '-8.1651' : point === 1 ? 'NULL' : '0'},0,0.1,-0.2,UNHEX(SHA2('${battery}:${cycle}:${step}:${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(',')}; |
| | | VALUES ${cycles.flatMap(cycle => [1, 2, 3].map(step => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 10 + step}','${cycle * 10 + step}',${cycle},${step},'${step === 3 ? '实际放电' : '实际充电' + step}','${step === 3 ? 'dc' : 'cc'}','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 === 3 ? 'NULL' : step === 1 ? '4.2' : step === 2 ? '4.333' : '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 detail = { experimentName: '连续实验甲', batteryFields: ['来源备注'], specialFields: [], fieldValues: { 来源备注: { 同名电池: '甲列值', 第二电池: '' } } } |
| | | await api(page, 'test/9301/console-detail', 'PUT', detail) |
| | | await api(page, 'test/9304/console-detail', 'PUT', { ...detail, experimentName: '连续实验乙', fieldValues: { 来源备注: { 同名电池: '乙列值' } } }) |
| | | expect((await api(page, 'test/9301')).data.consoleDetail).toEqual(detail) |
| | | 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}`) |
| | | if (kind === 'splice') { |
| | | draft.selections = [selection(9301), selection(9301, 9401), selection(9304)] |
| | | Object.assign(draft.settings, { fieldStructure: [{ name: '来源备注', category: 'battery', displayName: '来源备注', unit: '' }] }) |
| | | } |
| | | 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[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.formation.items[0].chargeSteps['1'].map((step: any) => step.direction)).toEqual(['CHARGE', 'CHARGE', 'DISCHARGE']) |
| | | expect(saved.formation.items[0].chargeSteps['1'].filter((step: any) => step.direction === 'CHARGE').map((step: any) => step.endVoltage)).toEqual([4.2, 4.333]) |
| | | expect(saved.splice.segments[0].statistics['1'].energyEfficiency).toBeCloseTo(124.467303453, 6) |
| | | for (const kind of kinds) { |
| | | const rows = kind === 'splice' ? saved[kind].segments : saved[kind].items |
| | | if (kind === 'splice') expect(rows[0].consoleDetail.fieldValues.来源备注).toBe('甲列值') |
| | | else expect(rows[0].source.consoleDetail.fieldValues.来源备注).toEqual({ 同名电池: '甲列值' }) |
| | | await page.goto(`/report-${kind}/${saved[kind].id}/edit?mode=view`) |
| | | if (kind === 'splice') await expect(page.locator('.splice-snapshot')).toContainText('连续实验甲') |
| | | else await expect.poll(() => page.locator('input').evaluateAll(elements => elements.some(element => element.value === '连续实验甲'))).toBe(true) |
| | | if (kind === 'formation') { |
| | | await expect(page.locator('.cycle-card[data-cycle="1"]')).toContainText('4.333 V') |
| | | for (const group of ['charge', 'discharge']) { |
| | | const canvas = page.locator(`[data-group="${group}"] .chart-card canvas`).first() |
| | | await expect(canvas).toBeVisible() |
| | | await expect.poll(() => canvas.evaluate((element: HTMLCanvasElement) => element.getContext('2d')!.getImageData(0, 0, element.width, element.height).data.some(value => value > 0))).toBe(true) |
| | | } |
| | | for (const step of [1, 2, 3]) { |
| | | expect(saved.formation.items[0].stepCurves['1'][step].records).toHaveLength(3) |
| | | expect(saved.formation.items[0].stepCurves['1'][step].records.every((record: any) => record.stepNo === step)).toBe(true) |
| | | } |
| | | } |
| | | } |
| | | expect(saved.splice.segments[1].consoleDetail.fieldValues.来源备注).toBe('') |
| | | expect(saved.splice.segments[2].consoleDetail.experimentName).toBe('连续实验乙') |
| | | await expect(page.locator('.splice-chart[data-series-count="1"]')).toHaveCount(2) |
| | | await expect(page.locator('.splice-snapshot')).toContainText('甲列值') |
| | | await expect(page.locator('.splice-snapshot')).toContainText('乙列值') |
| | | await api(page, 'test/9301/console-detail', 'PUT', { ...detail, experimentName: '来源后来实验' }) |
| | | |
| | | 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)) |
| | | const row = rows.find((item: any) => item.cycleNo === 1 && item.batteryNo === '同名电池' && (kind === 'STEP' ? item.stepNo === 3 : kind === 'REALTIME' ? item.stepNo === 1 : true)) |
| | | expect(row).toBeTruthy() |
| | | await api(page, 'test/9301/console-data/actions', 'POST', { |
| | | dataType: kind, operationType: 'EDIT', scopeSummary: `${kind} 连续验收修订`, reason: '核对三类报表版本独立', |
| | |
| | | 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: '仅调整名称' } |
| | | const edit = { ...payload(kind, `连续编辑-${kind}`), settings: first.settings, 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) } |
| | |
| | | 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 |
| | | const refreshedRows = kind === 'splice' ? refreshed[kind].segments : refreshed[kind].items |
| | | expect((kind === 'splice' ? refreshedRows[0].consoleDetail : refreshedRows[0].source.consoleDetail).experimentName).toBe('来源后来实验') |
| | | 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((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: '删源后保留快照', |
| | | ...payload(kind, `删源编辑-${kind}`), settings: refreshed[kind].settings, 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`) |
| | |
| | | const popup = await popupPromise |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await expect(popup.locator('body')).toContainText(`连续验收-${kind}`) |
| | | await expect(popup.locator('body')).toContainText('连续实验甲') |
| | | await expect(popup.locator('body')).toContainText('甲列值') |
| | | if (kind === 'formation') { |
| | | await expect(popup.locator('body')).toContainText('4.2 V') |
| | | await expect(popup.locator('body')).toContainText('4.333 V') |
| | | } |
| | | 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) |
| | |
| | | expect((await api(page, 'test/9303/data/records')).data).toEqual(before) |
| | | }) |
| | | |
| | | test('多电池循环覆盖不同,图上来源分界仍位于该测试最早拼接循环', async ({ page }) => { |
| | | 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)`) |
| | |
| | | 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]) |
| | | // 每个测试电池单独接续;旧的按测试共享偏移断言不适用于新版段语义。 |
| | | expect(saved.segments.map((segment: any) => segment.offset)).toEqual([0, 3, 4, 5]) |
| | | expect(saved.cycleNos).toEqual([1, 3, 5, 6, 8, 9, 11, 12]) |
| | | 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) |
| | | await expect(lines).toHaveCount(4) |
| | | 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) |
| | | // 图中四条分界必须按循环1、6、9、12的实际间距呈现,而非按电池等距分组。 |
| | | const span = positions[3]! - positions[0]! |
| | | expect((positions[1]! - positions[0]!) / span).toBeCloseTo(5 / 11, 2) |
| | | expect((positions[2]! - positions[0]!) / span).toBeCloseTo(8 / 11, 2) |
| | | await page.screenshot({ path: 'tests/e2e/.artifacts/ticket11-asymmetric-cycles.png', fullPage: true }) |
| | | }) |
| | | |
| | | test('拼接应用同名共享与控制台字段模板后两行独立编辑、保存和打印', async ({ page }) => { |
| | | await login(page) |
| | | const fieldDefinitionId = Number(stack.queryDatabase("SELECT field_id FROM t_battery_group_field WHERE field_name='F'")) |
| | | for (const id of [9302, 9304]) await api(page, `test/${id}/console-detail`, 'PUT', { |
| | | experimentName: `同名身份实验${id}`, batteryFields: ['F'], specialFields: [], |
| | | fieldValues: { F: { 同名电池: `控制台F${id}`, ...(id === 9302 ? { 第二电池: '' } : {}) } }, |
| | | }) |
| | | await api(page, 'splice-report/templates', 'POST', { name: '同名身份组合模板', fields: [ |
| | | { name: 'F', category: 'battery', displayName: '共享F', unit: '', fieldDefinitionId }, |
| | | ] }) |
| | | await page.goto('/report-splice/new') |
| | | await page.getByPlaceholder('请输入拼接报表名称').fill('同名身份独立拼接') |
| | | for (const name of ['连续来源2', '连续来源4']) { |
| | | 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 page.getByRole('button', { name: '选择字段模板', exact: true }).click() |
| | | await page.getByRole('dialog', { name: '选择拼接字段模板', exact: true }).getByText('同名身份组合模板', { exact: true }).click() |
| | | await page.getByRole('button', { name: '应用模板', exact: true }).click() |
| | | const shared = page.locator('.report-field-table tbody tr').filter({ has: page.locator('td').filter({ hasText: /^共享F$/ }) }).locator('input') |
| | | const manual = page.locator('.source-detail-table tbody tr').filter({ has: page.locator('td').filter({ hasText: /^F(电池字段)$/ }) }).locator('input') |
| | | await expect(manual.nth(0)).toHaveValue('控制台F9302') |
| | | await shared.nth(0).fill('共享报表甲') |
| | | await expect(manual.nth(0)).toHaveValue('控制台F9302') |
| | | await manual.nth(0).fill('人工报表甲') |
| | | await expect(shared.nth(0)).toHaveValue('共享报表甲') |
| | | await expect(manual.nth(1)).toHaveValue('控制台F9304') |
| | | const save = page.waitForResponse(response => response.url().endsWith('/splice-report') && response.request().method() === 'POST') |
| | | await page.getByRole('button', { name: '保存报表', exact: true }).click() |
| | | const response = await (await save).json() |
| | | expect(response.code, response.msg).toBe(200) |
| | | expect(response.data.settings.fieldStructure).toHaveLength(1) |
| | | const saved = (await api(page, `splice-report/${response.data.id}`)).data |
| | | await page.goto(`/report-splice/${saved.id}/edit?mode=view`) |
| | | await page.reload() |
| | | await expect(page.locator('.splice-snapshot')).toContainText('共享报表甲') |
| | | await expect(page.locator('.splice-snapshot')).toContainText('人工报表甲') |
| | | expect((await api(page, 'test/9302')).data.consoleDetail.fieldValues.F.同名电池).toBe('控制台F9302') |
| | | await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } }) |
| | | const popupEvent = page.waitForEvent('popup') |
| | | await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click() |
| | | const popup = await popupEvent |
| | | await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true') |
| | | await expect(popup.locator('body')).toContainText('共享报表甲') |
| | | await expect(popup.locator('body')).toContainText('人工报表甲') |
| | | const sharedPrintRow = popup.locator('.report-field-section tr').filter({ has: popup.locator('td').filter({ hasText: /^共享F$/ }) }) |
| | | const manualPrintRow = popup.locator('.source-detail-section tr').filter({ has: popup.locator('td').filter({ hasText: /^F(电池字段)$/ }) }) |
| | | await expect(sharedPrintRow).toContainText('共享报表甲') |
| | | await expect(sharedPrintRow).not.toContainText('人工报表甲') |
| | | await expect(manualPrintRow).toContainText('人工报表甲') |
| | | await expect(manualPrintRow).not.toContainText('共享报表甲') |
| | | await popup.pdf({ path: 'tests/e2e/.artifacts/ticket08-field-identities.pdf', preferCSSPageSize: true, printBackground: true }) |
| | | await popup.close() |
| | | }) |