yeg
2026-09-08 f419eefc754bf8a4a59035ef51b907d31c99cced
同步化成来源详情表单修复基线

# Conflicts:
# src/utils/formationSavedReportPrint.ts
2个文件已添加
12个文件已修改
355 ■■■■■ 已修改文件
src/api/formationReports.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/spliceReports.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationReportFields.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationSavedReportPrint.ts 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/formationSourceDetails.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/FormationReportForm.vue 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/compare-field-templates.spec.ts 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/formation-source-details.spec.ts 172 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/splice-battery-segments.spec.ts 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/support/ticket04-stack.ts 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-chart-display-units.test.mjs 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-live-values.contract.test.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-console-live.contract.test.mjs 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/test-ticket23-console-step-regression.test.mjs 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/formationReports.ts
@@ -1,9 +1,9 @@
import { request, type ApiResponse } from './request'
import type { PersistedTestRecord } from './tests'
import type { PersistedTestRecord, TestConsoleDetail } from './tests'
import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
export interface FormationSelection { testId: number; testBatteryId: number }
export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string }
export interface FormationCandidate { selection: FormationSelection; name: string; type: string; groupId: number; batteryGroup: string; batteryNo: string; consoleDetail?: TestConsoleDetail | null }
export interface FormationField { key: string; scope: 'detail' | 'cycle'; kind: 'battery' | 'metric' | 'manual'; displayName: string; unit: string; fieldDefinitionId?: number | null }
export interface FormationChargeStep { stepNo: number; name: string; stepType: string; endVoltage: number | null; direction?: 'CHARGE' | 'DISCHARGE' | 'UNKNOWN' }
export interface FormationItem {
@@ -18,7 +18,7 @@
}
export interface FormationMatrix { items: FormationItem[]; cycleNos: number[] }
export interface FormationStepSelection { charge: number[]; discharge: number[] }
export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; manualChargeSteps?: Record<string, number[]>; curveStepSelections?: Record<string, FormationStepSelection>; hiddenSeriesKeys?: string[]; activeCycle?: number | null }
export interface FormationSettings { fields: FormationField[]; values: Record<string, string>; chargeStepSelections: Record<string, number>; metrics?: ReportMetricKey[]; seriesColors?: Record<string, string>; manualChargeSteps?: Record<string, number[]>; curveStepSelections?: Record<string, FormationStepSelection>; hiddenSeriesKeys?: string[]; activeCycle?: number | null; sourceDetailOverrides?: Record<string, { experimentName?: string; fieldValues?: Record<string, string> }> }
export interface FormationSummary { id: number; name: string; creator: string; createdAt: number; testName?: string; batteryGroup?: string }
export interface FormationVersion { versionNo: number; operatorName: string; createdAt: number; changeSummary: string }
export interface SavedFormationReport extends FormationSummary, FormationMatrix {
src/api/spliceReports.ts
@@ -46,11 +46,14 @@
export function spliceSourceValue(segment: SpliceSegment, settings: SpliceSettings, field: SpliceField) {
  const manual = settings.detailFieldValues?.[field.name]?.[spliceSourceKey(segment.source)]
  if (manual !== undefined) return manual
  // 控制台详情只有名称,没有共享字段身份;模板字段一旦带 ID,必须按 ID 取组值,不能被同名详情冒认。
  if (field.fieldDefinitionId) {
    return segment.businessFields?.find(value => value.fieldDefinitionId === field.fieldDefinitionId)?.fieldValue ?? ''
  }
  const detail = segment.consoleDetail?.fieldValues[field.name]
  if (detail !== undefined) return detail
  if (segment.consoleDetail?.batteryFields.includes(field.name) || segment.consoleDetail?.specialFields.includes(field.name)) return ''
  // 用户明确选择的共享字段仍按其身份提供组值,不冒充缺失的控制台人工字段。
  return field.fieldDefinitionId ? segment.businessFields?.find(value => value.fieldDefinitionId === field.fieldDefinitionId)?.fieldValue ?? '' : ''
  return ''
}
export function spliceExperiment(segment: SpliceSegment, settings: SpliceSettings) {
  return settings.sourceExperimentNames?.[spliceSourceKey(segment.source)] ?? segment.consoleDetail?.experimentName ?? ''
src/utils/formationReportFields.ts
@@ -17,8 +17,8 @@
    ['efficiency', '充放电效率', '%'], ['energyEfficiency', '能量效率', '%'],
  ].map(([key, displayName, unit]) => ({ key: key!, scope: 'cycle' as const, kind: 'metric' as const, displayName: displayName!, unit: unit! })),
  ...[
    ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克瓦充电量'], ['chargePerGramTotal', '每克瓦完整充电量'],
    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF完整容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点完整容量'],
    ['method2', '测试方法2'], ['manual', '数字'], ['chargePerGram', '每克N充电量'], ['chargePerGramTotal', '每克F充电量'],
    ['capacity1v', '1.0V容量'], ['capacity1vf', '1.0VF克容量'], ['dischargeInflection', '拐点容量'], ['dischargeInflectionTotal', '拐点F克容量'],
  ].map(([key, displayName]) => ({ key: key!, scope: 'cycle' as const, kind: 'manual' as const, displayName: displayName!, unit: '' })),
]
src/utils/formationSavedReportPrint.ts
@@ -7,6 +7,7 @@
import { formationChartOption, formationCurveMetrics, formationSeriesColor, formationSeriesName } from './formationCycleChart'
import { formationChargeSteps, formationStepGroups, formationStepSeries, formationStepChartOption } from './formationStepChart'
import { openSavedReportPrint } from './savedReportPrint'
import { formationSourceFields, formationDetailValue } from './formationSourceDetails'
use([LineChart, GridComponent, LegendComponent, SVGRenderer])
function escapeHtml(value: unknown) {
@@ -17,7 +18,8 @@
function printHtml(record: SavedFormationReport) {
  const colors = record.settings.seriesColors ?? {}
  const items = [...new Set(record.items.map(item => item.source.selection.testId))].flatMap(id => record.items.filter(item => item.source.selection.testId === id))
  const cell = (value: unknown) => `<td>${escapeHtml(value == null || value === '' ? '缺失' : value)}</td>`
  const cell = (value: unknown, preserveEmpty = false) => `<td>${escapeHtml(value == null || (!preserveEmpty && value === '') ? '缺失' : value)}</td>`
  const sourceFields = formationSourceFields(items.map(item => item.source), record.settings.sourceDetailOverrides)
  const tables = (cycle: number) => {
    const result: string[] = []
    // 每组最多四列,按原测试分组顺序打印全部矩阵;长表重复列头并允许跨页。
@@ -38,11 +40,17 @@
          return `工步${step.stepNo} · ${step.name || '未命名工步'} · ${step.endVoltage ?? '缺失'}${step.endVoltage == null ? '' : ' V'}`
        }))])
      }
      if (!cycle) {
        rows.push(['实验名称', ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides))])
        for (const field of sourceFields)
          rows.push([`${field.name}(${field.category === 'battery' ? '电池字段' : '特殊字段'})`, ...group.map(item => formationDetailValue(item.source, record.settings.sourceDetailOverrides, field))])
      }
      for (const field of record.settings.fields.filter(field => field.scope === (cycle ? 'cycle' : 'detail'))) {
        rows.push([formationFieldLabel(field), ...group.map(item => value(item, () =>
          record.settings.values[formationValueKey(item, cycle, field)] ?? formationSourceValue(item, cycle, field)))])
      }
      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell).join('')}</tr>`).join('')}</tbody></table>`)
      // 来源详情的明确空字符串保持空白,既有指标缺失仍使用缺失标记。
      result.push(`<table class="matrix-table"><thead><tr><th>${cycle ? `循环${cycle} / 测试` : '测试'}</th>${group.map(item => `<th>${escapeHtml(item.source.name)}</th>`).join('')}</tr><tr><th>电池组 / 电池号</th>${group.map(item => `<th>${escapeHtml(item.source.batteryGroup)} / ${escapeHtml(item.source.batteryNo)}</th>`).join('')}</tr></thead><tbody>${rows.map((row, index) => `<tr>${row.map(value => cell(value, !cycle && index <= sourceFields.length)).join('')}</tr>`).join('')}</tbody></table>`)
    }
    return result.join('')
  }
src/utils/formationSourceDetails.ts
New file
@@ -0,0 +1,23 @@
import type { FormationCandidate, FormationSettings } from '@/api/formationReports'
export function formationSourceKey(source: FormationCandidate) { return `${source.selection.testId}|${source.selection.testBatteryId}` }
/** 控制台两类字段按名称保留,人工覆盖不进入业务字段和指标计算。 */
export function formationSourceFields(sources: FormationCandidate[], overrides: FormationSettings['sourceDetailOverrides']) {
  const fields = sources.flatMap(source => (['battery', 'special'] as const).flatMap(category =>
    (source.consoleDetail?.[category === 'battery' ? 'batteryFields' : 'specialFields'] || []).map(name => ({ key: `${category}|${name}`, name, category }))))
  for (const source of sources) for (const key of Object.keys(overrides?.[formationSourceKey(source)]?.fieldValues || {})) {
    const category = key.startsWith('battery|') ? 'battery' : 'special'
    fields.push({ key, name: key.slice(category.length + 1), category })
  }
  return [...new Map(fields.map(field => [field.key, field])).values()]
}
/** 明确人工空字符串优先于冻结来源;缺失字段不从其他列补值。 */
export function formationDetailValue(source: FormationCandidate, overrides: FormationSettings['sourceDetailOverrides'], field?: ReturnType<typeof formationSourceFields>[number]) {
  const manual = overrides?.[formationSourceKey(source)]
  if (!field) return manual?.experimentName ?? source.consoleDetail?.experimentName ?? ''
  if (manual?.fieldValues?.[field.key] !== undefined) return manual.fieldValues[field.key]!
  const names = source.consoleDetail?.[field.category === 'battery' ? 'batteryFields' : 'specialFields'] || []
  return names.includes(field.name) ? source.consoleDetail?.fieldValues[field.name]?.[source.batteryNo] ?? '' : ''
}
src/views/FormationReportForm.vue
@@ -14,6 +14,7 @@
import type { ReportMetricKey } from '@/utils/reportMeasurementChart'
import { openFormationSavedReportPrint } from '@/utils/formationSavedReportPrint'
import FormationFieldControls from '@/components/FormationFieldControls.vue'
import { formationSourceKey, formationSourceFields, formationDetailValue } from '@/utils/formationSourceDetails'
import FormationStepCurves from '@/components/FormationStepCurves.vue'
import { formationChargeSteps, formationCycleKey } from '@/utils/formationStepChart'
import type { FormationSettings, FormationStepSelection } from '@/api/formationReports'
@@ -50,6 +51,13 @@
const cycleNos = ref<number[]>([])
const fields = ref<FormationField[]>(structuredClone(defaultFormationFields))
const values = ref<Record<string, string>>({})
const sourceDetailOverrides = ref<NonNullable<FormationSettings['sourceDetailOverrides']>>({})
const sourceFields = computed(() => formationSourceFields(reportItems.value.map(item => item.source), sourceDetailOverrides.value))
function setDetail(source: FormationCandidate, value: string, field?: typeof sourceFields.value[number]) {
  const key = formationSourceKey(source)
  const current = sourceDetailOverrides.value[key] ?? {}
  sourceDetailOverrides.value[key] = field ? { ...current, fieldValues: { ...current.fieldValues, [field.key]: value } } : { ...current, experimentName: value }
}
const chargeStepSelections = ref<Record<string, number>>({})
const metrics = ref<ReportMetricKey[]>(formationCurveMetrics.map(metric => metric.key))
const seriesColors = ref<Record<string, string>>({})
@@ -58,7 +66,7 @@
const curveStepSelections = ref<Record<string, FormationStepSelection>>({})
const hiddenSeriesKeys = ref<string[]>([])
const actualSteps = computed(() => !saved.value || saved.value.curveMode === 'actual-steps')
const stepSettings = computed<FormationSettings>(() => ({ fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value }))
const stepSettings = computed<FormationSettings>(() => ({ fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value, manualChargeSteps: manualChargeSteps.value, curveStepSelections: curveStepSelections.value, hiddenSeriesKeys: hiddenSeriesKeys.value, activeCycle: activeCycle.value }))
function changeStepSettings(settings: Partial<FormationSettings>) {
  if (settings.curveStepSelections) curveStepSelections.value = settings.curveStepSelections
  if (settings.hiddenSeriesKeys) hiddenSeriesKeys.value = settings.hiddenSeriesKeys
@@ -95,11 +103,12 @@
}
function chargeKey(item: FormationItem, cycle: number) { return `${item.source.selection.testId}|${item.source.selection.testBatteryId}|${cycle}` }
function updateSeriesColor(item: FormationItem, color: string | null) { if (color) seriesColors.value[formationSeriesKey(item)] = color }
function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) }
function formContent() { return JSON.stringify({ name: reportName.value, description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }) }
function restore(record: SavedFormationReport) {
  saved.value = record; reportName.value = record.name; description.value = record.description || ''
  items.value = record.items; cycleNos.value = record.cycleNos
  fields.value = record.settings.fields; values.value = record.settings.values; chargeStepSelections.value = record.settings.chargeStepSelections || {}
  sourceDetailOverrides.value = record.settings.sourceDetailOverrides ?? {}
  metrics.value = record.settings.metrics ?? formationCurveMetrics.map(metric => metric.key)
  seriesColors.value = record.settings.seriesColors ?? {}
  manualChargeSteps.value = record.settings.manualChargeSteps ?? {}; curveStepSelections.value = record.settings.curveStepSelections ?? {}
@@ -111,6 +120,7 @@
    (field.scope === 'detail' ? [0] : item.cycleNos).map(cycle => formationValueKey(item, cycle, field)))))
  values.value = Object.fromEntries(Object.entries(values.value).filter(([key]) => allowed.has(key)))
  const sourceKeys = new Set(items.value.flatMap(item => [formationSeriesKey(item), ...Object.entries(item.stepCurves ?? {}).flatMap(([cycle, steps]) => Object.keys(steps).map(step => `${formationSeriesKey(item)}|${cycle}|${step}`))]))
  sourceDetailOverrides.value = Object.fromEntries(Object.entries(sourceDetailOverrides.value).filter(([key]) => sourceKeys.has(key)))
  hiddenSeriesKeys.value = hiddenSeriesKeys.value.filter(key => sourceKeys.has(key))
  const cycleKeys = new Set(items.value.flatMap(item => item.cycleNos.map(cycle => formationCycleKey(item, cycle))))
  manualChargeSteps.value = Object.fromEntries(Object.entries(manualChargeSteps.value).filter(([key]) => cycleKeys.has(key)))
@@ -135,7 +145,7 @@
  saving.value = true
  try {
    pruneValues()
    const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
    const content = { name: reportName.value.trim(), description: description.value, settings: actualSteps.value ? stepSettings.value : { fields: fields.value, values: values.value, sourceDetailOverrides: sourceDetailOverrides.value, chargeStepSelections: chargeStepSelections.value, metrics: metrics.value, seriesColors: seriesColors.value } }
    const result = editingId.value && saved.value
      ? await editFormationReport(editingId.value, { ...content, baseVersionNo: saved.value.versionNo, changeSummary: changeSummary.value.trim() })
      : await createFormationReport({ ...content, selections: items.value.map(item => item.source.selection) })
@@ -206,6 +216,7 @@
  loading.value = true; loadError.value = ''
  previewSequence++; previewLoading.value = false; previewError.value = ''
  saved.value = null; items.value = []; cycleNos.value = []; values.value = {}; chargeStepSelections.value = {}; selectedKeys.value = []
  sourceDetailOverrides.value = {}
  try {
    const permissions = (await getUserInfo()).permissions || []
    const has = (action: string) => permissions.includes('*:*:*') || permissions.includes(`system:formation-report:${action}`)
@@ -249,7 +260,10 @@
          <div class="report-data-table-wrap"><table class="report-data-table" :style="tableStyle">
            <thead><tr><th>测试</th><th v-for="column in reportColumns" :key="column.id" :colspan="column.items.length">{{ column.name }}</th></tr>
              <tr><th>电池组 / 电池号</th><th v-for="item in reportItems" :key="selectionKey(item.source)">{{ item.source.batteryGroup }} / {{ item.source.batteryNo }}</th></tr></thead>
            <tbody><tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
            <tbody>
              <tr><td>实验名称</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 实验名称`" @update:model-value="setDetail(item.source, $event)" /></td></tr>
              <tr v-for="field in sourceFields" :key="`console:${field.key}`"><td>{{ field.name }}({{ field.category === 'battery' ? '电池字段' : '特殊字段' }})</td><td v-for="item in reportItems" :key="selectionKey(item.source)"><el-input :model-value="formationDetailValue(item.source, sourceDetailOverrides, field)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} 详情 ${field.name}`" @update:model-value="setDetail(item.source, $event, field)" /></td></tr>
              <tr v-for="field in detailFields" :key="field.key"><td>{{ formationFieldLabel(field) }}</td><td v-for="item in reportItems" :key="selectionKey(item.source)">
              <el-input :model-value="inputValue(item, 0, field)" @update:model-value="setValue(item, 0, field, $event)" :disabled="readonly" :aria-label="`${item.source.name} ${item.source.batteryNo} ${field.displayName}`" :placeholder="field.kind === 'manual' ? '人工填写' : '指标缺失'" size="small" />
            </td></tr></tbody>
          </table></div>
tests/e2e/compare-field-templates.spec.ts
@@ -132,9 +132,10 @@
  const popupWait = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const popup = await popupWait
  await expect(popup.locator('.detail-table')).toContainText('乙温升摘要 (℃)')
  await expect(popup.locator('.detail-table')).toContainText('样品质量 (g)')
  await expect(popup.locator('.detail-table')).toContainText('20')
  const printedFields = popup.locator('.detail-table').filter({ hasText: '乙温升摘要 (℃)' })
  await expect(printedFields).toContainText('乙温升摘要 (℃)')
  await expect(printedFields).toContainText('样品质量 (g)')
  await expect(printedFields).toContainText('20')
  await expect(popup.locator('.chart-card')).toContainText('mA')
  await popup.close()
  await page.goto(`/report-compare/${fresh.id}/edit?mode=edit`)
tests/e2e/formation-source-details.spec.ts
New file
@@ -0,0 +1,172 @@
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 () => {
  await stack.start()
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9501,'化成详情甲组','甲'),(9502,'化成详情乙组','乙');
    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value) SELECT 9501,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(9501,'化成'),(9502,'其他'),(9503,'化成扩展');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status)
    VALUES(9501,'化成详情甲',1,1,9501,'验收','COMPLETED','ENDED'),(9502,'化成详情乙',1,1,9501,'验收','COMPLETED','ENDED'),
      (9503,'名称含化成',1,1,9502,'验收','COMPLETED','ENDED'),(9504,'未完成化成',1,1,9501,'验收','WAITING','NOT_STARTED'),
      (9505,'近似化成类型',1,1,9503,'验收','COMPLETED','ENDED'),(9506,'无详情化成',1,1,9501,'验收','COMPLETED','ENDED');
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
    VALUES(9501,9501,9501,'同名电池',9501),(9502,9501,9501,'二号',9502),(9503,9502,9502,'同名电池',9503),
      (9504,9503,9501,'其他',9504),(9505,9504,9501,'未完成',9505),(9506,9505,9501,'近似',9506),(9507,9506,9502,'无详情',9507);
    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 ${[[9501,9501,1],[9501,9501,3],[9501,9502,2],[9502,9503,3]].map(([id,battery,cycle]) => `(${id},${battery},${battery},'formation-detail','source:${battery}','${cycle}','${cycle}',${cycle},'2026-09-01 00:00:00.123','2026-09-01 00:00:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',${cycle === 3 ? 'NULL' : '1.2'},0,1,2,UNHEX(SHA2('${battery}:${cycle}',256)))`).join(',')};
  `)
})
test.afterAll(async () => stack.stop())
async function login(page: Page) {
  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$/)
}
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 body = await (await page.request.fetch(`${stack.backendUrl}/system/${path}`, { method, data, headers: { Authorization: `Bearer ${token}` } })).json()
  expect(body.code, `${path}: ${body.msg}`).toBe(code)
  return body.data
}
const selections = [{ testId: 9501, testBatteryId: 9501 }, { testId: 9501, testBatteryId: 9502 }, { testId: 9502, testBatteryId: 9503 }]
const detailA = { experimentName: '实验甲', batteryFields: ['F', '人工字段'], specialFields: ['说明'], fieldValues: { F: { 同名电池: '人工F不能参与公式', 二号: '' }, 人工字段: { 同名电池: '甲值', 二号: '' }, 说明: { 同名电池: '甲说明', 二号: '二号说明' } } }
const detailB = { experimentName: '实验乙', batteryFields: ['人工字段'], specialFields: ['说明'], fieldValues: { 人工字段: { 同名电池: '乙值' }, 说明: { 同名电池: '' } } }
test('化成按来源冻结详情和空字符串,独立编辑、字段模板、更新和删源打印', async ({ page }) => {
  const errors: string[] = []
  page.on('pageerror', error => errors.push(error.message))
  await login(page)
  await api(page, 'test/9501/console-detail', 'PUT', detailA)
  await api(page, 'test/9502/console-detail', 'PUT', detailB)
  const sources = await api(page, 'formation-report/sources')
  for (const id of [9503,9504,9505]) expect(sources.map((source: any) => source.selection.testId)).not.toContain(id)
  for (const selection of [{ testId: 9503, testBatteryId: 9504 }, { testId: 9504, testBatteryId: 9505 }, { testId: 9505, testBatteryId: 9506 }, { testId: 9501, testBatteryId: 9503 }])
    await api(page, 'formation-report/preview', 'POST', [selection], 500)
  const preview = await api(page, 'formation-report/preview', 'POST', [...selections, { testId: 9506, testBatteryId: 9507 }])
  expect(preview.cycleNos).toEqual([1,2,3])
  expect(preview.items.map((item: any) => item.cycleNos)).toEqual([[1,3],[2],[3],[]])
  expect(preview.items[0].source.consoleDetail.fieldValues.人工字段).toEqual({ 同名电池: '甲值' })
  expect(preview.items[1].source.consoleDetail.fieldValues.人工字段).toEqual({ 二号: '' })
  expect(preview.items[3].source.consoleDetail).toBeNull()
  await page.goto('/report-formation/new')
  await page.locator('.source-panel .el-select').click()
  for (const name of ['化成详情甲 / 化成详情甲组 / 同名电池', '化成详情甲 / 化成详情甲组 / 二号', '化成详情乙 / 化成详情乙组 / 同名电池'])
    await page.getByRole('option', { name, exact: true }).click()
  await page.getByRole('combobox', { name: '化成来源', exact: true }).press('Escape')
  await expect(page.locator('.cycle-card')).toHaveCount(3)
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true })).toHaveValue('实验甲')
  await expect(page.getByRole('textbox', { name: '化成详情乙 同名电池 实验名称', exact: true })).toHaveValue('实验乙')
  await expect(page.getByRole('textbox', { name: '化成详情甲 二号 详情 人工字段', exact: true })).toHaveValue('')
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 F', exact: true })).toHaveValue('12.5')
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 F', exact: true })).toHaveValue('人工F不能参与公式')
  for (const label of ['每克N充电量','每克F充电量','1.0V容量','1.0VF克容量','拐点容量','拐点F克容量'])
    await expect(page.getByRole('textbox', { name: `化成详情甲 同名电池 循环1 ${label}`, exact: true })).toHaveValue('')
  await expect(page.locator('[data-cycle="2"]')).toContainText('无该循环')
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 循环3 充电容量', exact: true })).toHaveAttribute('placeholder', '指标缺失')
  await page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true }).fill('报表覆盖')
  await page.getByRole('textbox', { name: '化成详情乙 同名电池 实验名称', exact: true }).fill('')
  await page.getByPlaceholder('请输入报表名称').fill('化成详情独立报表')
  // 保存前来源再次变化:后端冻结最新值,同时保留报表内明确调整。
  await api(page, 'test/9501/console-detail', 'PUT', { ...detailA, experimentName: '保存时实验甲' })
  await page.getByRole('button', { name: '保存报表', exact: true }).click()
  await expect(page).toHaveURL(/\/report-formation\/\d+\/edit\?mode=view$/)
  const id = Number(page.url().match(/formation\/(\d+)/)![1])
  const first = await api(page, `formation-report/${id}`)
  expect(first.items.find((item: any) => item.source.selection.testBatteryId === 9501).source.consoleDetail.experimentName).toBe('保存时实验甲')
  expect(first.settings.sourceDetailOverrides['9501|9501'].fieldValues['battery|人工字段']).toBe('报表覆盖')
  expect(first.settings.sourceDetailOverrides['9502|9503'].experimentName).toBe('')
  expect((await api(page, 'test/9501')).consoleDetail.fieldValues.人工字段.同名电池).toBe('甲值')
  for (const sourceDetailOverrides of [
    { '9506|9507': { experimentName: '伪造来源' } },
    { '9501|9501|1|1': { experimentName: '曲线范围不是详情身份' } },
    { '9501|9501': { fieldValues: { voltage: '不能冒充设备字段' } } },
  ]) {
    await api(page, `formation-report/${id}/versions`, 'POST', {
      name: first.name, description: '', baseVersionNo: 1, changeSummary: '拒绝伪造详情', settings: { ...first.settings, sourceDetailOverrides },
    }, 500)
  }
  expect((await api(page, `formation-report/${id}`)).versionNo).toBe(1)
  const fields = first.settings.fields
  const template = await api(page, 'formation-report/templates', 'POST', { name: '纯结构模板', fields })
  expect(template).toBeGreaterThan(0)
  expect(JSON.stringify(await api(page, 'formation-report/templates'))).not.toContain('报表覆盖')
  await page.reload()
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
  await page.getByRole('button', { name: '编辑', exact: true }).click()
  await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
  await page.getByRole('dialog').getByText('纯结构模板', { exact: true }).click()
  await page.getByRole('dialog').getByRole('button', { name: '应用模板', exact: true }).click()
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
  await page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true }).fill('报表实验甲')
  await page.getByPlaceholder('请输入修改说明').fill('详情独立编辑')
  const versionResponse = page.waitForResponse(response => response.url().endsWith(`/formation-report/${id}/versions`) && response.request().method() === 'POST')
  await page.getByRole('button', { name: '保存新版本', exact: true }).click()
  const versionBody = await (await versionResponse).json()
  expect(versionBody.code, versionBody.msg).toBe(200)
  expect(versionBody.data.versionNo).toBe(2)
  await expect(page).toHaveURL(/mode=view$/)
  await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 实验名称', exact: true })).toHaveValue('报表实验甲')
  const second = await api(page, `formation-report/${id}`)
  expect(second.versionNo).toBe(2)
  expect(second.items).toEqual(first.items)
  await api(page, `formation-report/${id}/versions`, 'POST', { name: first.name, description: '', baseVersionNo: 1, changeSummary: '陈旧', settings: first.settings }, 500)
  await api(page, 'test/9501/console-detail', 'PUT', { ...detailA, experimentName: '最新来源实验' })
  const refreshed = await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '明确更新' })
  expect(refreshed.items.find((item: any) => item.source.selection.testBatteryId === 9501).source.consoleDetail.experimentName).toBe('最新来源实验')
  expect(refreshed.settings.sourceDetailOverrides).toEqual(second.settings.sourceDetailOverrides)
  expect(await api(page, `formation-report/${id}/versions/1`)).toEqual(first)
  for (const width of [1440,390]) {
    await page.setViewportSize({ width, height: 900 })
    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    await page.screenshot({ path: `tests/e2e/.artifacts/formation-details-${width}.png`, fullPage: true })
  }
  for (const source of [9501,9502]) await api(page, `test/${source}`, 'DELETE')
  await api(page, `formation-report/${id}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '删源后' }, 500)
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  for (const version of [1,3]) {
    await page.goto(`/report-formation/${id}/edit?mode=view&version=${version}`)
    await expect(page.getByRole('textbox', { name: '化成详情甲 同名电池 详情 人工字段', exact: true })).toHaveValue('报表覆盖')
    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('.matrix-table').first()).toContainText('报表覆盖')
    await expect(popup.locator('.matrix-table').first()).toContainText(version === 1 ? '保存时实验甲' : '报表实验甲')
    const headers = await popup.locator('.matrix-table').first().locator('thead tr').last().locator('th').allTextContents()
    const emptyColumn = headers.findIndex(header => header.includes('二号'))
    expect(emptyColumn).toBeGreaterThan(0)
    await expect(popup.locator('.matrix-table').first().locator('tr').filter({ hasText: '人工字段(电池字段)' }).locator('td').nth(emptyColumn)).toHaveText('')
    await popup.pdf({ path: `tests/e2e/.artifacts/formation-details-v${version}.pdf`, preferCSSPageSize: true, printBackground: true })
    await popup.screenshot({ path: `tests/e2e/.artifacts/formation-details-print-v${version}.png`, fullPage: true })
    await popup.close()
  }
  expect(errors).toEqual([])
})
test('旧版缺详情和旧字段名称原样保留,仅新版本纠正名称', async ({ page }) => {
  await login(page)
  const settings = { fields: [{ key: 'chargePerGram', scope: 'cycle', kind: 'manual', displayName: '每克瓦充电量', unit: '' }], values: {}, chargeStepSelections: {} }
  const created = await api(page, 'formation-report', 'POST', { name: '旧化成详情版本', description: '', selections: [{ testId: 9506, testBatteryId: 9507 }], settings })
  expect(created.settings.fields[0].displayName).toBe('每克N充电量')
  // 隔离库构造旧版事实,用于验证读取不静默改写历史,也不借来源补造详情。
  stack.executeIsolatedSql(`UPDATE t_formation_report_version SET snapshot_json=JSON_SET(JSON_REMOVE(snapshot_json,'$.items[0].source.consoleDetail','$.settings.sourceDetailOverrides'),'$.settings.fields[0].displayName','每克瓦充电量') WHERE report_id=${created.id}`)
  const old = await api(page, `formation-report/${created.id}`)
  await api(page, 'test/9506/console-detail', 'PUT', { experimentName: '后来才填写的实验', batteryFields: [], specialFields: [], fieldValues: {} })
  await page.goto(`/report-formation/${created.id}/edit?mode=view`)
  await expect(page.getByRole('textbox', { name: '无详情化成 无详情 实验名称', exact: true })).toHaveValue('')
  expect(old.items[0].source.consoleDetail).toBeUndefined()
  expect(old.settings.fields[0].displayName).toBe('每克瓦充电量')
  const edited = await api(page, `formation-report/${created.id}/versions`, 'POST', { name: '新版本名称', description: '', baseVersionNo: 1, changeSummary: '只调整名称', settings: old.settings })
  expect(edited.items).toEqual(old.items)
  expect(edited.settings.fields[0].displayName).toBe('每克N充电量')
  const refreshed = await api(page, `formation-report/${created.id}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动带入详情' })
  expect(refreshed.items[0].source.consoleDetail.experimentName).toBe('后来才填写的实验')
  expect(await api(page, `formation-report/${created.id}/versions/1/print`)).toEqual(old)
})
tests/e2e/splice-battery-segments.spec.ts
@@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
import { spliceSourceValue } from '@/api/spliceReports'
const stack = new Ticket04Stack()
const selection = (testBatteryId: number) => ({ testId: testBatteryId < 9703 ? 9701 : 9702, testBatteryId })
@@ -25,6 +26,16 @@
  }
})
test.afterAll(async () => stack.stop())
test('同名控制台字段与共享字段按身份隔离', () => {
  const segment: any = {
    source: { selection: { testId: 1, testBatteryId: 2 }, batteryNo: '1号' },
    businessFields: [{ fieldDefinitionId: 10, fieldName: '质量', fieldValue: '共享值' }],
    consoleDetail: { batteryFields: ['质量'], specialFields: [], fieldValues: { 质量: '控制台值' } },
  }
  expect(spliceSourceValue(segment, {}, { name: '质量', category: 'battery', displayName: '质量', unit: 'kg', fieldDefinitionId: 10 })).toBe('共享值')
  expect(spliceSourceValue(segment, {}, { name: '质量', category: 'battery', displayName: '质量', unit: '', fieldDefinitionId: null })).toBe('控制台值')
})
async function login(page: Page) {
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill('admin')
tests/e2e/support/ticket04-stack.ts
@@ -168,6 +168,15 @@
    if (!this.docker) return
    this.dockerRun(['rm', '--force', this.mysqlContainer], true)
    this.dockerRun(['rm', '--force', this.redisContainer], true)
    // WSL 的端口转发可能晚于容器退出释放;确认本次端口已释放后才交给下一份验收。
    await waitUntil(async () => {
      try {
        await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable))
        return true
      } catch {
        return false
      }
    }, '本次隔离环境的端口未在清理后释放', 30_000)
  }
  private dockerRun(args: string[], allowFailure = false) {
tests/test-chart-display-units.test.mjs
@@ -12,9 +12,10 @@
  return new Function(...Object.keys(bindings), 'toTestDisplayValue', script)(...Object.values(bindings), toTestDisplayValue)
}
async function printModule(name) {
async function measurementModule() {
  const name = 'reportMeasurementChart'
  const source = await readFile(new URL(`../src/utils/${name}.ts`, import.meta.url), 'utf8')
  const script = ts.transpileModule(`${source}\nexport { createChartSvg }`, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
  const script = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText
  const exports = {}
  new Function('exports', 'require', script)(exports, id => {
    assert.equal(id, './testDataUnits')
@@ -22,15 +23,14 @@
  })
  return exports
}
const compare = await printModule('compareReportPrint')
const formation = await printModule('formationReportPrint')
const { measurementChartOption } = await measurementModule()
test('工步图电流序列和纵轴同步为 mA,保留负数与零', async () => {
  const records = [-8.1651, 0, -8.1674].map((current, index) => ({ batteryNo: 'BAT-1', current, deviceTime: index * 60000, sourceType: 'SNAPSHOT' }))
  const { metricValue, chartOption } = await componentFunctions('components/ConsoleChartPanel.vue', ['metricValue', 'recordTime', 'chartOption'], {
    props: { cells: [{ no: 'BAT-1' }] }, selectedRecords: () => records, selectedCells: { value: [{ no: 'BAT-1' }] }, metricMatches: () => true, seriesColors: { value: ['#3978f6'] },
  })
  const option = chartOption(compare.compareMetricDefinitions.find(metric => metric.key === 'current'))
  const option = chartOption({ key: 'current', unit: 'mA' })
  assert.deepEqual(option.series[0].data, [-8165.1, 0, -8167.4])
  assert.equal(option.yAxis[0].name, 'mA')
  assert.ok(option.yAxis[0].min < -8167.4)
@@ -53,26 +53,31 @@
  assert.equal(funcs.dischargeCapacityPerFactor('BAT-1', 1), null)
})
test('三类演示报表曲线、偏移量、纵轴和打印统一换算', async () => {
  const common = { seriesColors: { value: ['#3978f6'] } }
  const compareChart = await componentFunctions('views/ReportCompareForm.vue', ['chartOption'], { ...common, chartSeries: { value: [{ name: '测试', batteryNo: 'BAT-1' }] } })
  const current = compare.compareMetricDefinitions.find(metric => metric.key === 'current')
  const option = compareChart.chartOption(current)
  assert.equal(option.yAxis[0].name, 'mA')
  assert.equal(option.series[0].data[0], 1100)
  assert.equal(option.yAxis[0].max, 1380)
  const svg = compare.createChartSvg(current, { detailTests: [{ name: '测试', batteryNo: 'BAT-1' }], seriesColors: [] })
  assert.match(svg, />mA</)
  assert.match(svg, />1380</)
  const formationChart = await componentFunctions('views/FormationReportForm.vue', ['chartOption'], { ...common, reportSamples: { value: ['BAT-1'] }, activeCycle: { value: 2 } })
  const formationCurrent = formation.formationMetricDefinitions.find(metric => metric.key === 'current')
  assert.equal(formationChart.chartOption(formationCurrent).series[0].data[0], 1212)
  const formationSvg = formation.createChartSvg(formationCurrent, { cycle: 2 }, { samples: ['BAT-1'], seriesColors: [] })
  assert.match(formationSvg, />mA</)
  assert.match(formationSvg, />1212</)
  const spliceChart = await componentFunctions('views/SpliceReportForm.vue', ['chartOption', 'formatMinutes'], { ...common, samples: { value: ['BAT-1'] }, selectedTests: { value: [{}, {}] }, timeline: { value: { segments: [], labels: [] } } })
  const spliceOption = spliceChart.chartOption(formationCurrent)
  assert.equal(spliceOption.series[0].data[0], 1200)
  assert.equal(spliceOption.series[0].data[7], 1215)
  assert.equal(spliceOption.yAxis[0].name, 'mA')
})
test('保存报表共用测量图按真实时间和单位绘制,显隐不改变完整数据范围', () => {
  const items = [
    { name: '测试甲 · 1号', zeroTime: 1000, records: [
      { deviceTime: 1000, collectorDeviceTime: 9000, current: -0.125, voltage: 3.2, temperature: 0 },
      { deviceTime: 18000, collectorDeviceTime: 22000, current: null, voltage: null, temperature: null },
      { deviceTime: 181000, collectorDeviceTime: 182000, current: 0, voltage: 0, temperature: -5 },
    ] },
    { name: '测试乙 · 1号', zeroTime: 3000, records: [{ deviceTime: 7203000, current: 0.25 }] },
  ]
  const before = structuredClone(items)
  const metric = { key: 'current', unit: 'mA' }
  const option = measurementChartOption(metric, items, ['#123456', '#abcdef'])
  assert.deepEqual(option.series[0].data, [[0, -125], [17, null], [180, 0]])
  assert.equal(option.series[0].connectNulls, false)
  assert.equal(option.xAxis.max, 7200)
  assert.equal(option.xAxis.axisLabel.formatter(1800), '0.5')
  assert.deepEqual(option.yAxis.map(axis => [axis.name, axis.min, axis.max]), [['mA', -143.75, 250], ['mA', -143.75, 250]])
  const hidden = measurementChartOption(metric, [{ ...items[0], hidden: true }, items[1]], ['#123456', '#abcdef'])
  assert.equal(hidden.series.length, 1)
  assert.equal(hidden.series[0].lineStyle.color, '#abcdef')
  assert.equal(hidden.xAxis.max, option.xAxis.max)
  assert.equal(hidden.yAxis[0].min, option.yAxis[0].min)
  assert.deepEqual(items, before)
  const voltage = measurementChartOption({ key: 'voltage', unit: 'V' }, [items[0]], ['#123456'])
  assert.deepEqual(voltage.series[0].data, [[0, 3.2], [17, null], [180, 0]])
  const temperature = measurementChartOption({ key: 'temperature', unit: '℃' }, [items[0]], ['#123456'])
  assert.deepEqual(temperature.series[0].data, [[8, 0], [21, null], [181, -5]])
})
tests/test-console-live-values.contract.test.mjs
@@ -2,7 +2,7 @@
import { readFile } from 'node:fs/promises'
const view = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8')
const liveService = await readFile(new URL('../../BatteryDetection/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java', import.meta.url), 'utf8')
const liveService = await readFile(new URL('../../BatteryDetection/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java', import.meta.url), 'utf8')
assert.match(view, /metric\(\['VOLTAGE', 'voltage'\], cabinetSource, 'voltageV'\)/)
assert.match(view, /metric\(\['CURRENT', 'current'\], cabinetSource, 'currentA'\)/)
tests/test-console-live.contract.test.mjs
@@ -13,7 +13,7 @@
assert.match(view, /liveConnection/)
assert.match(view, /不纳入结果/)
assert.doesNotMatch(view, /localStorage\.(setItem|getItem)\([^)]*test/i)
assert.match(view, /item\.sourceType !== 'COLLECTOR' \|\| !item\.status \|\| item\.status === 'OPEN'/)
assert.match(view, /filter\(item => !item\.status \|\| item\.status === 'OPEN'\)/)
assert.doesNotMatch(view, /完整性检查/)
assert.doesNotMatch(view, /integrity-panel/)
assert.match(api, /COMPLETION_PENDING: '检测完成'/)
tests/test-ticket23-console-step-regression.test.mjs
@@ -3,7 +3,7 @@
const view = await readFile(new URL('../src/views/TestConsole.vue', import.meta.url), 'utf8')
const dataList = await readFile(new URL('../src/components/ConsoleDataListPanel.vue', import.meta.url), 'utf8')
const liveService = await readFile(new URL('../../BatteryDetection/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TTestLiveServiceImpl.java', import.meta.url), 'utf8')
const liveService = await readFile(new URL('../../BatteryDetection/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java', import.meta.url), 'utf8')
assert.match(view, /<strong>项目参数<\/strong><span>STEP PARAMETERS<\/span>/)
assert.match(view, /testPlan\?\.rows/)
@@ -28,18 +28,21 @@
assert.doesNotMatch(dataList, /<th>逻辑工步<\/th>/)
assert.doesNotMatch(dataList, /<th>实际工步<\/th>/)
assert.match(dataList, /activeTab\.value === 'step' \? stepRows\.value/)
assert.doesNotMatch(view, /:logic-step-mappings=/)
assert.doesNotMatch(view, /:plan-steps=/)
assert.doesNotMatch(view.match(/<ConsoleDataListPanel\b[^]*?\/>/)[0], /:logic-step-mappings=/)
assert.doesNotMatch(view.match(/<ConsoleDataListPanel\b[^]*?\/>/)[0], /:plan-steps=/)
assert.doesNotMatch(dataList, /mappedStepRows/)
assert.doesNotMatch(dataList, /observedStepPositions/)
assert.doesNotMatch(dataList, /sumValue\('capacity'\)/)
assert.doesNotMatch(dataList, /logical-step:/)
assert.match(dataList, /const filteredRows = computed\(\(\) =>\n  sourceRows\.value\.filter/)
assert.match(dataList, /const filteredRows = computed\(\(\) =>\r?\n  sourceRows\.value\.filter/)
assert.match(dataList, /:total="filteredRows\.length"/)
assert.doesNotMatch(dataList, /receptionIntervals/)
assert.match(dataList, /physicalIds/)
assert.match(dataList, /props\.receptionStatus === 'NOT_STARTED'/)
assert.match(liveService, /simulator\.btsTest\(battery\.getBinding\(\)\.getDevid\(\), battery\.getBinding\(\)\.getChlid\(\), 1\)/)
assert.match(await readFile(new URL('../../BatteryDetection/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java', import.meta.url), 'utf8'), /Integer cyclePosition/)
// 接收状态只触发同步,当前有效历史不因接收暂停或未开始而在前端清空。
assert.match(dataList, /if \(next !== previous\) void loadRecords\(\)/)
assert.match(liveService, /adapter\.testPlan\(new ChannelIdentity\(binding\.getAdapterCode\(\), binding\.getDevtype\(\)/)
assert.match(liveService, /source\.currentCycleNo\(\), source\.currentStepNo\(\)/)
assert.doesNotMatch(liveService, /simulator\.btsTest/)
assert.match(await readFile(new URL('../../BatteryDetection/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java', import.meta.url), 'utf8'), /new PlanRow\("STEP", index \+ 1, index \+ 1,/)
console.log('Ticket23 实时工步方案与数据列表工步映射回归契约通过')