yeg
2026-09-08 e2754e4e6af5f3c6ac7185972ead8ddb4ca805af
完善对比曲线显隐冻结及保存版本打印验收
1个文件已添加
5个文件已修改
244 ■■■■■ 已修改文件
src/api/compareReports.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareReportChart.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/compareReportPrint.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/reportMeasurementChart.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/ReportCompareForm.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-chart-interactions.spec.ts 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/compareReports.ts
@@ -47,6 +47,7 @@
export interface CompareSettings {
  metrics: CompareMetricKey[]
  seriesColors: string[]
  hiddenSeriesKeys?: string[]
  batteryTemplateFields: string[]
  specialFields: string[]
  detailFieldValues: Record<string, Record<string, string>>
src/utils/compareReportChart.ts
@@ -1,13 +1,13 @@
import type { CompareItem } from '@/api/compareReports'
import { compareSelectionKey, type CompareItem } from '@/api/compareReports'
import { measurementChartOption, type ReportMetricKey } from './reportMeasurementChart'
/** 完整所选范围参与图例身份,避免同名电池的不同循环或工步混淆。 */
export function compareSeriesName(item: CompareItem) {
  const source = item.source
  return `${source.name} · ${source.batteryNo} · 循环${source.selection.cycleNo} · ${source.process}`
  return `${source.name} (#${source.selection.testId}) · ${source.batteryGroup} · ${source.batteryNo} · 循环${source.selection.cycleNo} · ${source.process}`
}
/** 对比页面与打印共享冻结测量的绘图口径。 */
export function compareChartOption(metric: { key: ReportMetricKey; unit: string }, items: CompareItem[], colors: string[]) {
  return measurementChartOption(metric, items.map(item => ({ ...item, name: compareSeriesName(item) })), colors)
export function compareChartOption(metric: { key: ReportMetricKey; unit: string }, items: CompareItem[], colors: string[], hiddenSeriesKeys: string[] = []) {
  return measurementChartOption(metric, items.map(item => ({ ...item, name: compareSeriesName(item), hidden: hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) })), colors)
}
src/utils/compareReportPrint.ts
@@ -34,7 +34,7 @@
function chartSvg(metric: CompareMetricDefinition, record: SavedCompareReport) {
  const chart = init(null, undefined, { renderer: 'svg', ssr: true, width: 900, height: 300 })
  try {
    chart.setOption({ ...compareChartOption(metric, record.items, record.settings.seriesColors), tooltip: { show: false } })
    chart.setOption({ ...compareChartOption(metric, record.items, record.settings.seriesColors, record.settings.hiddenSeriesKeys), tooltip: { show: false } })
    return chart.renderToSVGString()
  } finally { chart.dispose() }
}
@@ -67,7 +67,8 @@
  const legend = record.items.map((item, index) => {
    const color = record.settings.seriesColors[index % record.settings.seriesColors.length]
    const safeColor = color && /^#[0-9a-f]{6}$/i.test(color) ? color : '#52606d'
    return `<span><i style="background:${safeColor}"></i>${escapeHtml(compareSeriesName(item))}</span>`
    const hidden = record.settings.hiddenSeriesKeys?.includes(compareSelectionKey(item.source.selection))
    return `<span${hidden ? ' style="text-decoration:line-through;color:#8795a3"' : ''}><i style="background:${safeColor}"></i>${escapeHtml(compareSeriesName(item))}</span>`
  }).join('')
  const charts = compareMetricDefinitions.filter(metric => record.settings.metrics.includes(metric.key)).map(metric =>
    `<article class="chart-card"><header><strong>${metric.title} (${escapeHtml(record.displayUnits[metric.key] || metric.unit)})</strong></header>${chartSvg(metric, record)}<div class="chart-inner-legend">${legend}</div></article>`).join('')
src/utils/reportMeasurementChart.ts
@@ -2,7 +2,7 @@
import { toTestDisplayValue } from './testDataUnits'
export type ReportMetricKey = 'voltage' | 'current' | 'positiveVoltage' | 'negativeVoltage' | 'temperature'
export interface MeasurementSeries { name: string; zeroTime: number | null; records: PersistedTestRecord[] }
export interface MeasurementSeries { name: string; zeroTime: number | null; records: PersistedTestRecord[]; hidden?: boolean }
/** 页面和打印共用测量、时间及单位口径;不连接缺失值,也不限制曲线条数。 */
export function measurementChartOption(metric: { key: ReportMetricKey; unit: string }, items: MeasurementSeries[], colors: string[]) {
@@ -18,6 +18,8 @@
    value === null ? [low, high] : [Math.min(low, value), Math.max(high, value)], [min, max]), [0, 0])
  const range = Math.max(dataMax - dataMin, 1)
  const timeMax = series.reduce((max, points) => points.reduce((value, [time]) => Math.max(value, time), max), 0)
  const timeUnit = timeMax >= 3600 ? 3600 : timeMax >= 180 ? 60 : 1
  const timeLabel = timeUnit === 3600 ? 'h' : timeUnit === 60 ? 'min' : 's'
  const axisMax = Math.max(metric.unit === '℃' ? Math.ceil(dataMax / 10) * 10 : Math.ceil(dataMax * 10) / 10, 1)
  const yAxis = (position: 'left' | 'right') => ({
    type: 'value', name: metric.unit, position, min: dataMin - range * 0.05, max: axisMax,
@@ -29,13 +31,15 @@
  })
  return {
    animation: false, color: colors, grid: { top: 40, right: 32, bottom: 42, left: 32, containLabel: true },
    // 使用 ECharts 原生图例承载曲线显隐;外部图例仍用于来源说明和改色。
    tooltip: { trigger: 'axis' }, legend: { show: true, type: 'scroll', top: 0, left: 32, right: 32, itemWidth: 12, itemHeight: 8, textStyle: { color: '#52606d', fontSize: 10 } },
    xAxis: { type: 'value', min: 0, max: timeMax > 0 ? timeMax : undefined, name: '相对时间 / s', nameLocation: 'middle', nameGap: 28,
      axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10 },
    tooltip: { trigger: 'axis' }, legend: { show: false },
    xAxis: { type: 'value', min: 0, max: timeMax > 0 ? timeMax : undefined, splitNumber: 4, name: `相对时间 / ${timeLabel}`, nameLocation: 'middle', nameGap: 28,
      axisLine: { lineStyle: { color: '#cbd8e4' } }, axisLabel: { color: '#8795a3', fontSize: 10, hideOverlap: true,
        formatter: (value: number) => Number((value / timeUnit).toFixed(3)).toString() },
      nameTextStyle: { color: '#26333f', fontSize: 11, fontWeight: 600 } },
    yAxis: [yAxis('left'), yAxis('right')],
    series: items.map((item, index) => ({ name: item.name, type: 'line', smooth: false,
      connectNulls: false, showSymbol: true, symbolSize: 5, lineStyle: { width: 2 }, data: series[index] || [] })),
    // 全部冻结点先参与坐标计算;隐藏仅控制渲染,不重算零点、不缩短范围、不移动其他来源颜色。
    series: items.flatMap((item, index) => item.hidden ? [] : [{ name: item.name, type: 'line', smooth: false,
      connectNulls: false, showSymbol: true, symbolSize: 5, itemStyle: { color: colors[index % colors.length] },
      lineStyle: { width: 2, color: colors[index % colors.length] }, data: series[index] || [] }]),
  }
}
src/views/ReportCompareForm.vue
@@ -11,7 +11,7 @@
import { getUserInfo } from '@/api/auth'
import type { CompareMetricKey, CompareTestSelection } from '@/stores/compareReports'
import { compareSelectionKey, createCompareReport, editCompareReport, getCompareReport, getCompareSources, previewCompareReport, refreshCompareSource,
  type CompareCandidate, type CompareItem, type CompareSelection, type SavedCompareReport } from '@/api/compareReports'
  type CompareCandidate, type CompareItem, type SavedCompareReport } from '@/api/compareReports'
import { listCompareFieldTemplates, createCompareFieldTemplate, updateCompareFieldTemplate, type CompareField, type CompareFieldTemplate } from '@/api/compareReports'
import { compareFields, compareFieldLabel, compareBusinessFieldValue } from '@/utils/compareReportFields'
import { compareMetricDefinitions as metricDefinitions, openCompareReportPrint } from '@/utils/compareReportPrint'
@@ -44,7 +44,7 @@
const experimentName = ref('')
const description = ref('')
const batteryGroup = ref('')
type SelectedTest = CompareTestSelection & { selection: CompareSelection }
type SelectedTest = CompareTestSelection & CompareCandidate
const selectedTests = ref<SelectedTest[]>([])
const candidates = ref<CompareCandidate[]>([])
const candidateTests = computed(() => [...new Map(candidates.value.map(item => [item.selection.testId, toSelectedTest(item)])).values()])
@@ -82,6 +82,7 @@
  temperature: true,
})
const seriesColors = ref(['#3978f6', '#7d42d8', '#f08a3e'])
const hiddenSeriesKeys = ref<string[]>([])
const visibleMetrics = computed(() => metricDefinitions.filter(metric => metricVisible[metric.key]))
const selectedTemplate = computed(() => persistedTemplates.value.find(item => `remote:${item.id}` === selectedTemplateKey.value))
@@ -138,7 +139,7 @@
    if (detailFieldValues[field][key] === undefined) {
      const source = sourceItems.value.find(value => compareSelectionKey(value.source.selection) === key)
      const sourceDetail = source?.source.consoleDetail
      const sourceValue = sourceDetail?.fieldValues?.[field]?.[source.source.batteryNo]
      const sourceValue = sourceDetail?.fieldValues?.[field]?.[item.batteryNo]
      detailFieldValues[field][key] = sourceValue !== undefined
        ? sourceValue
        : compareBusinessFieldValue(fieldDescriptor(field), source)
@@ -157,6 +158,7 @@
  selectedTests.value = record.items.map(item => toSelectedTest(item.source))
  metricDefinitions.forEach(metric => { metricVisible[metric.key] = record.settings.metrics.includes(metric.key) })
  seriesColors.value = [...record.settings.seriesColors]
  hiddenSeriesKeys.value = [...(record.settings.hiddenSeriesKeys || [])]
  batteryFields.value = [...record.settings.batteryTemplateFields]
  specialFields.value = [...record.settings.specialFields]
  fieldStructure.value = (record.settings.fieldStructure || []).map(field => ({ ...field }))
@@ -172,6 +174,13 @@
function updateSeriesColor(index: number, color: string | null) {
  if (color) seriesColors.value[index] = color
}
function toggleSeries(item: SelectedTest) {
  if (readonly.value) return
  const key = detailKey(item)
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.includes(key)
    ? hiddenSeriesKeys.value.filter(value => value !== key) : [...hiddenSeriesKeys.value, key]
}
detailFields.value.forEach(ensureDetailFieldValues)
@@ -194,6 +203,7 @@
  selectedTemplateKey.value = ''
  selectedBatteryTemplateName.value = '自定义'
  seriesColors.value = ['#3978f6', '#7d42d8', '#f08a3e']
  hiddenSeriesKeys.value = []
  metricDefinitions.forEach(metric => { metricVisible[metric.key] = true })
  Object.keys(detailFieldValues).forEach(field => delete detailFieldValues[field])
  loadError.value = ''
@@ -361,6 +371,7 @@
    return
  }
  const index = selectedTests.value.indexOf(itemToRemove)
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => key !== detailKey(itemToRemove))
  if (index >= 0) { selectedTests.value.splice(index, 1); seriesColors.value.splice(index, 1) }
  await refreshPreview()
}
@@ -455,6 +466,7 @@
    name: reportName.value, description: description.value, experimentName: experimentName.value,
    settings: {
      metrics: visibleMetrics.value.map(item => item.key), seriesColors: seriesColors.value.slice(0, selectedTests.value.length),
      hiddenSeriesKeys: [...hiddenSeriesKeys.value],
      batteryTemplateFields: [...batteryFields.value], specialFields: [...specialFields.value],
      fieldStructure: detailFields.value.map(fieldDescriptor),
      detailFieldValues: Object.fromEntries(detailFields.value.map(field => [field,
@@ -587,9 +599,10 @@
    <section class="metrics-panel">
      <div v-if="!readonly" class="metric-switches"><label v-for="metric in metricDefinitions" :key="metric.key"><span>{{ metric.title }}</span><el-switch v-model="metricVisible[metric.key]" inline-prompt active-text="显" inactive-text="隐" /></label></div>
      <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><span>{{ item.name }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</span></div></div>
      <div v-if="chartSeries.length" class="chart-legend-bar"><strong>对比图例</strong><div v-for="(item, index) in chartSeries" :key="detailKey(item)" class="chart-legend-item"><el-color-picker v-if="!readonly" class="legend-color-picker" :model-value="seriesColors[index]" :show-alpha="false" popper-class="legend-color-popper" :aria-label="`修改${item.name}颜色`" @change="updateSeriesColor(index, $event)" /><i v-else class="series-swatch" :style="{ background: seriesColors[index] }" /><button type="button" class="legend-name" :class="{ 'is-hidden': hiddenSeriesKeys.includes(detailKey(item)) }" :aria-pressed="!hiddenSeriesKeys.includes(detailKey(item))" :disabled="readonly" @click="toggleSeries(item)">{{ item.name }} (#{{ item.id }}) · {{ item.batteryGroup }} · {{ item.batteryNo }} · 循环{{ item.cycle }} · {{ item.process }}</button></div></div>
      <!-- 报表切换和比对项移除时完整替换配置,避免图表库将颜色数组缩短误判为组件合并。 -->
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors)" :update-options="{ notMerge: true }" autoresize /></article></div>
      <div v-if="!visibleMetrics.length" class="chart-empty">当前未启用曲线指标</div>
      <div class="charts-grid"><article v-for="metric in visibleMetrics" :key="metric.key" class="chart-card"><header><strong>{{ metric.title }}</strong></header><VChart class="metric-chart" :option="compareChartOption(metric, sourceItems, seriesColors, hiddenSeriesKeys)" :update-options="{ notMerge: true }" autoresize /><div v-if="!sourceItems.some(item => !hiddenSeriesKeys.includes(compareSelectionKey(item.source.selection)) && item.records.some(row => (metric.key === 'voltage' || metric.key === 'current' ? row.deviceTime : row.collectorDeviceTime) != null && (metric.key === 'positiveVoltage' ? row.positivePotential : metric.key === 'negativeVoltage' ? row.negativePotential : row[metric.key]) != null))" class="chart-empty">{{ sourceItems.length && hiddenSeriesKeys.length === sourceItems.length ? '当前全部曲线已隐藏' : '所选范围暂无该指标数据' }}</div></article></div>
    </section>
    <footer class="form-actions">
@@ -691,6 +704,10 @@
.chart-legend-bar > strong { color: #26333f; font-size: 12px; font-weight: 600; }
.chart-legend-item { display: inline-flex; align-items: center; gap: 7px; min-width: 0; color: #52606d; font-size: 12px; }
.chart-legend-item span { max-width: 360px; overflow-wrap: anywhere; }
.legend-name { max-width: 100%; border: 0; padding: 4px 0; background: transparent; color: inherit; font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
.legend-name.is-hidden { color: #8795a3; text-decoration: line-through; }
.legend-name:disabled { cursor: default; }
.chart-empty { padding: 12px; color: #8795a3; font-size: 12px; text-align: center; }
.legend-color-picker { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; }
.legend-color-picker :deep(.el-color-picker__trigger) { width: 14px !important; height: 14px !important; padding: 0 !important; border: 0 !important; border-radius: 2px; background: transparent !important; box-shadow: none !important; overflow: hidden; }
.legend-color-picker :deep(.el-color-picker__color) { width: 14px; height: 14px; border: 0; border-radius: 2px; box-shadow: 0 0 0 1px rgba(36, 54, 71, .18); }
tests/e2e/compare-chart-interactions.spec.ts
New file
@@ -0,0 +1,185 @@
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
test.use({ actionTimeout: 20_000 })
test.beforeAll(async () => stack.start())
test.afterAll(async () => stack.stop())
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.data
}
function seedSources() {
  stack.executeIsolatedSql(`
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9401,'图表验收');
    ${[9401, 9402].map(id => `
      INSERT INTO t_battery_group(group_id,group_name,model) VALUES(${id},'图表组${id}','型号');
      INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
        VALUES(${id},'图表测试${id}',1,1,9401,'隔离验收','COMPLETED','ENDED');
      INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id) VALUES(${id},${id},${id},'BAT-1',${id});
      INSERT INTO t_detection_cabinet(cabinet_id,adapter_code,cabinet_code,cabinet_name,vendor_device_type,vendor_device_id)
        VALUES(${id},'chart-${id}','chart-${id}','验收柜',27,${id});
      INSERT INTO t_detection_channel(channel_id,adapter_code,cabinet_id,devtype,devid,subdevid,chlid) VALUES(${id},'chart-${id}',${id},27,${id},1,1);
      INSERT INTO t_test_channel_binding(binding_id,test_id,test_battery_id,channel_id) VALUES(${id},${id},${id},${id});
      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 ${[1, 2].flatMap(step => Array.from({ length: step === 1 ? 65 : 3 }, (_, point) => {
        const seq = step * 100 + point
        const offset = step === 1 ? point * 113 + (point % 3) : point * 7
        const base = Date.parse(`2026-09-01T0${id - 9400}:00:00.123Z`) + step * 10_000_000
        const date = (time: number) => `'${new Date(time).toISOString().replace('T', ' ').replace('Z', '')}'`
        return `(${id},${id},${id},'chart-${id}','chart-${id}:27:${id}:1:1','${seq}',27,${id},1,1,${seq},${date(base + offset * 1000 + 10000)},${date(base + offset * 1000)},${date(base + offset * 1000 + 11000)},1,${step},'${step === 1 ? '充电' : '放电'}',${3 + point / 100},${point === 1 ? 'NULL' : point === 0 ? '0' : '-0.125'},${point === 1 ? 'NULL' : point === 0 ? '0' : '25'},${step === 2 ? 'NULL' : '0.1'},-0.2,UNHEX(SHA2('${seq}',256)))`
      })).join(',')};
    `).join('\n')}
  `)
}
test('对比图例按来源冻结,完整测量时间与双轴在重开和指定版打印中重现', async ({ page }) => {
  seedSources()
  const pageErrors: string[] = []
  page.on('pageerror', error => pageErrors.push(error.message))
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill('admin')
  await page.locator('input[autocomplete="current-password"]').fill('admin123')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/dashboard$/)
  await page.goto('/report-compare/new')
  await page.getByPlaceholder('请输入报表名称').fill('图表交互冻结验收')
  for (const [id, step] of [[9401, 1], [9401, 2], [9402, 1]]) {
    await page.getByRole('button', { name: '添加比较', exact: true }).click()
    const dialog = page.getByRole('dialog', { name: '添加对比' })
    for (const [label, value] of [['选择测试', `图表测试${id}(${id})`], ['选择循环', '1'], ['选择工步', `${step}-${step === 1 ? '充电' : '放电'}`]]) {
      const control = dialog.locator('label').filter({ hasText: label }).locator('.el-select')
      await control.locator('.el-select__wrapper').click()
      const listId = await control.getByRole('combobox').getAttribute('aria-controls')
      await page.locator(`#${listId}`).getByRole('option', { name: value, exact: true }).click()
    }
    await dialog.getByRole('button', { name: '添加', exact: true }).click()
    await expect(page.getByRole('button', { name: '添加比较', exact: true })).toBeEnabled()
  }
  const legends = page.locator('.legend-name')
  await expect(legends).toHaveCount(3)
  await legends.nth(0).click()
  await expect(legends.nth(0)).toHaveAttribute('aria-pressed', 'false')
  await expect(legends.nth(1)).toHaveAttribute('aria-pressed', 'true')
  await expect(legends.nth(2)).toHaveAttribute('aria-pressed', 'true')
  await legends.nth(1).click()
  await legends.nth(2).click()
  await expect(page.getByText('当前全部曲线已隐藏', { exact: true })).toHaveCount(5)
  await legends.nth(1).click()
  await legends.nth(2).click()
  await page.locator('.chart-legend-item').nth(2).locator('.el-color-picker').click()
  const panel = page.locator('.el-color-picker__panel:visible')
  await panel.locator('input').fill('#12AB34')
  await panel.getByRole('button', { name: /确定|确认|OK/ }).click()
  await expect(legends.nth(2)).toHaveAttribute('aria-pressed', 'true')
  await page.locator('.metric-switches label').filter({ hasText: '温度' }).locator('.el-switch__core').click()
  const created = page.waitForResponse(response => response.url().endsWith('/system/compare-report') && response.request().method() === 'POST')
  await page.getByRole('button', { name: '保存报表', exact: true }).click()
  const v1 = (await (await created).json()).data
  expect(v1.settings.hiddenSeriesKeys).toEqual(['9401|9401|1|1|'])
  expect(v1.settings.seriesColors[2].toLowerCase()).toBe('#12ab34')
  expect(v1.settings.metrics).not.toContain('temperature')
  expect(v1.items.map((item: any) => item.records.length)).toEqual([65, 3, 65])
  expect(v1.units.current).toBe('A')
  expect(v1.displayUnits.current).toBe('mA')
  for (const item of v1.items) {
    expect(item.zeroTime).toBe(Math.min(...item.records.flatMap((row: any) => [row.deviceTime, row.collectorDeviceTime])))
    expect(item.records[0].deviceTime - item.records[0].collectorDeviceTime).toBe(10000)
    expect(item.records.find((row: any) => row.recordNo === String(item.source.selection.stepNo * 100 + 2)).current).toBe(-0.125)
  }
  const plot = await page.evaluate(async record => {
    const path = '/src/utils/compareReportChart.ts'
    const { compareChartOption } = await import(path)
    const result: any = {}
    for (const [key, unit] of [['current', 'mA'], ['temperature', '℃'], ['positiveVoltage', 'V']]) {
      const all = compareChartOption({ key, unit }, record.items, record.settings.seriesColors)
      const hidden = compareChartOption({ key, unit }, record.items, record.settings.seriesColors, record.settings.hiddenSeriesKeys)
      result[key] = { all, hidden, tickLabels: [0, 1800, 3600].map(value => all.xAxis.axisLabel.formatter(value)) }
    }
    const short = compareChartOption({ key: 'current', unit: 'mA' }, [record.items[1]], record.settings.seriesColors)
    return { ...result, short }
  }, v1)
  expect(plot.current.all.series[0].data).toHaveLength(65)
  expect(plot.current.all.series[0].data.slice(0, 3)).toEqual([[10, 0], [124, null], [238, -125]])
  expect(plot.temperature.all.series[0].data.slice(0, 3)).toEqual([[0, 0], [114, null], [228, 25]])
  expect(plot.current.all.series.every((series: any) => series.connectNulls === false)).toBe(true)
  expect(plot.short.xAxis.name).toBe('相对时间 / s')
  expect(plot.current.all.xAxis.name).toBe('相对时间 / h')
  expect(plot.current.tickLabels).toEqual(['0', '0.5', '1'])
  for (const key of ['current', 'temperature', 'positiveVoltage']) {
    expect(plot[key].all.xAxis.max).toBe(plot[key].hidden.xAxis.max)
    expect(plot[key].hidden.series).toHaveLength(2)
    expect(plot[key].all.yAxis.map((axis: any) => [axis.min, axis.max, axis.name])).toEqual(plot[key].hidden.yAxis.map((axis: any) => [axis.min, axis.max, axis.name]))
    const [left, right] = plot[key].all.yAxis
    expect([left.min, left.max, left.name]).toEqual([right.min, right.max, right.name])
  }
  expect(plot.positiveVoltage.all.series[1].data.every((point: any) => point[1] === null)).toBe(true)
  await page.goto(`/report-compare/${v1.id}/edit?mode=edit`)
  await expect(legends.nth(0)).toHaveAttribute('aria-pressed', 'false')
  await page.reload()
  await expect(legends.nth(0)).toHaveAttribute('aria-pressed', 'false')
  await expect(page.locator('.metric-chart canvas')).toHaveCount(4)
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    await expect.poll(async () => (await page.locator('.metric-chart canvas').first().boundingBox())!.width).toBeGreaterThan(200)
    const count = await page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
      const data = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
      let green = 0
      for (let i = 0; i < data.length; i += 4) if (data[i + 1]! > data[i]! + 60 && data[i + 1]! > data[i + 2]! + 50 && data[i + 3]! > 100) green++
      return green
    })
    expect(count).toBeGreaterThan(100)
    await page.screenshot({ path: `tests/e2e/.artifacts/compare-interactions-${width}.png`, fullPage: true })
  }
  await page.setViewportSize({ width: 1440, height: 900 })
  await legends.nth(0).click()
  await legends.nth(1).click()
  await page.getByPlaceholder('请输入修改说明').fill('切换显示范围')
  const edited = page.waitForResponse(response => response.url().endsWith(`/compare-report/${v1.id}/versions`) && response.request().method() === 'POST')
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  const v2 = (await (await edited).json()).data
  expect(v2.settings.hiddenSeriesKeys).toEqual(['9401|9401|1|2|'])
  expect(v2.items).toEqual(v1.items)
  const baseEdit = { name: v2.name, description: v2.description, experimentName: v2.experimentName, baseVersionNo: 2, changeSummary: '无效身份', settings: v2.settings }
  for (const invalid of [['9402|9401|1|1|'], ['9401|9401|1|2|', '9401|9401|1|2|']]) {
    await api(page, `compare-report/${v1.id}/versions`, 'POST', { ...baseEdit, settings: { ...v2.settings, hiddenSeriesKeys: invalid } }, 500)
  }
  // 未携带新增设置的旧契约保持原快照结构,重开和打印默认显示全部曲线。
  const { hiddenSeriesKeys: _hiddenSeriesKeys, ...legacySettings } = v1.settings
  const legacy = await api(page, 'compare-report', 'POST', {
    name: '无显隐设置的保存版本', description: '', experimentName: '',
    selections: v1.items.map((item: any) => item.source.selection), settings: legacySettings,
  })
  expect(legacy.settings).not.toHaveProperty('hiddenSeriesKeys')
  await api(page, 'test/9401', 'DELETE')
  await api(page, 'test/9402', 'DELETE')
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  for (const version of [v1, v2, legacy]) {
    await page.goto(`/report-compare/${version.id}/edit?mode=view&version=${version.versionNo}`)
    if (version === legacy) await expect(page.locator('.legend-name[aria-pressed="true"]')).toHaveCount(3)
    else await expect(legends.nth(version.versionNo === 1 ? 0 : 1)).toHaveAttribute('aria-pressed', 'false')
    const popupPromise = page.waitForEvent('popup')
    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
    const popup = await popupPromise
    await expect(popup.locator('.chart-card')).toHaveCount(4)
    const voltage = popup.locator('.chart-card').first()
    await expect(voltage.locator('svg path[stroke="#12AB34"], svg path[stroke="#12ab34"]').first()).toBeAttached()
    if (version !== legacy) {
      const hiddenColor = version.settings.seriesColors[version.versionNo === 1 ? 0 : 1]
      await expect(voltage.locator(`svg path[stroke="${hiddenColor}"]`)).toHaveCount(0)
    }
    await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
    const artifact = version === legacy ? 'legacy' : `v${version.versionNo}`
    await popup.screenshot({ path: `tests/e2e/.artifacts/compare-interactions-print-${artifact}.png`, fullPage: true })
    await popup.pdf({ path: `tests/e2e/.artifacts/compare-interactions-print-${artifact}.pdf`, preferCSSPageSize: true, printBackground: true })
    expect(await api(page, `compare-report/${version.id}/versions/${version.versionNo}/print`)).toEqual(version)
    await popup.close()
  }
  expect(pageErrors).toEqual([])
})