yeg
2026-09-08 1ae9a0935c87e892514f20a4af39d66f993dec47
集成三类报表连续验收与同名字段回归
1个文件已修改
130 ■■■■■ 已修改文件
tests/e2e/data-analysis-integrated-acceptance.spec.ts 130 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/data-analysis-integrated-acceptance.spec.ts
@@ -45,7 +45,7 @@
    : 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)) }
}
@@ -67,9 +67,9 @@
    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(',')};
    `)
@@ -78,11 +78,19 @@
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)
@@ -92,13 +100,41 @@
  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: '核对三类报表版本独立',
@@ -113,7 +149,7 @@
    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) }
@@ -123,6 +159,8 @@
    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])
@@ -157,7 +195,7 @@
    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`)
@@ -172,6 +210,12 @@
    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)
@@ -466,7 +510,7 @@
  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)`)
@@ -476,13 +520,75 @@
    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()
})