yeg
2026-08-29 14d8f28a52dae109049902057aa3e8f8f9367bee
实现测试设备必填配置

测试创建和编辑页接入采集器通道必填、可选项与动态测量项。
补充后端事实回读、重复映射校验和 Ticket07 浏览器测试。
1个文件已添加
4个文件已修改
394 ■■■■■ 已修改文件
package.json 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/tests.ts 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/stores/tests.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/TestCreate.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/test-required-device-bindings.spec.ts 219 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json
@@ -9,7 +9,8 @@
    "preview": "vite preview",
    "test:e2e:ticket04": "playwright test tests/e2e/device-registration.spec.ts",
    "test:e2e:ticket05": "playwright test tests/e2e/shared-fields-and-battery-groups.spec.ts",
    "test:e2e:ticket06": "playwright test tests/e2e/collector-sampling.spec.ts"
    "test:e2e:ticket06": "playwright test tests/e2e/collector-sampling.spec.ts",
    "test:e2e:ticket07": "playwright test tests/e2e/test-required-device-bindings.spec.ts"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
src/api/tests.ts
@@ -1,8 +1,8 @@
import type { MonitoringStatus, TestRecord, TestStatus } from '@/stores/tests'
import type { ReceptionStatus, TestRecord, TestStatus } from '@/stores/tests'
import { request, type ApiResponse } from './request'
export type TestStatusCode = 'WAITING' | 'RUNNING' | 'PAUSED' | 'RUNNING_ABNORMAL' | 'COMPLETION_PENDING' | 'COMPLETED' | 'TERMINATED'
export type MonitoringStatusCode = 'NOT_MONITORED' | 'WAITING_DEVICE_START' | 'MONITORING' | 'BACKFILLING' | 'MONITORING_ENDED' | 'CANCELLED'
export type ReceptionStatusCode = 'NOT_STARTED' | 'RECEIVING' | 'PAUSED' | 'ENDED'
export interface TestListParams {
  pageNum: number
@@ -29,8 +29,14 @@
  relationId?: number
  groupId: number
  batteryNo: string
  collectorId: number | null
  collectorId: number
  binding: { bindingId?: number; channelId: number; expectedBarcode: string | null; claimStatus?: string }
  collectorChannels: Array<{
    mappingId?: number
    measurementCode: string
    measurementName: string
    channelCode: string
  }>
  fields: Array<{ fieldId?: number; fieldName: string; fieldValue: string }>
}
@@ -63,7 +69,14 @@
  principalName: string | null
  testTypeName: string | null
  testStatus: TestStatusCode
  monitoringStatus: MonitoringStatusCode
  receptionStatus: ReceptionStatusCode
  fieldSnapshots: Array<{
    snapshotId: number
    fieldDefinitionId: number
    fieldName: string
    fieldType: string
    fieldValue: string | null
  }>
}
export interface TestConfigurationOptions {
@@ -71,7 +84,7 @@
  testTypes: Array<{ testTypeId: number; testTypeName: string }>
  batteries: Array<{ relationId: number; groupId: number; groupName: string; batteryNo: string }>
  channels: Array<{ channelId: number; cabinetId: number; cabinetCode: string; cabinetName: string; adapterCode: string; devtype: number; devid: number; subdevid: number; chlid: number; displayNo: string | null }>
  collectors: Array<{ collectorId: number; collectorCode: string; collectorName: string }>
  collectors: Array<{ collectorId: number; collectorCode: string; collectorName: string; channelCount: number }>
}
interface TestDto {
@@ -81,7 +94,8 @@
  principalName: string | null
  testPurpose: string | null
  testStatus: TestStatusCode | null
  monitoringStatus: MonitoringStatusCode | null
  receptionStatus: ReceptionStatusCode | null
  batteryNo: string | null
  createTime: string | null
  startedAtUtc: string | null
  endedAtUtc: string | null
@@ -98,9 +112,8 @@
  WAITING: '待测试', RUNNING: '检测中', PAUSED: '暂停', RUNNING_ABNORMAL: '检测中(异常)',
  COMPLETION_PENDING: '完成确认中', COMPLETED: '检测完成', TERMINATED: '已终止',
}
const monitoringStatusMap: Record<MonitoringStatusCode, MonitoringStatus> = {
  NOT_MONITORED: '未监测', WAITING_DEVICE_START: '等待设备启动', MONITORING: '监测中',
  BACKFILLING: '补采中', MONITORING_ENDED: '监测结束', CANCELLED: '已取消',
const receptionStatusMap: Record<ReceptionStatusCode, ReceptionStatus> = {
  NOT_STARTED: '未开始接收', RECEIVING: '接收中', PAUSED: '暂停接收', ENDED: '已结束接收',
}
const legacyStatusMap: Record<string, TestStatusCode> = {
  '0': 'WAITING', '1': 'RUNNING', '2': 'RUNNING_ABNORMAL', '3': 'COMPLETED',
@@ -108,12 +121,12 @@
function mapTest(item: TestDto): TestRecord {
  const status = item.testStatus ? statusMap[item.testStatus] : '未知'
  const monitoringStatus = item.monitoringStatus ? monitoringStatusMap[item.monitoringStatus] : '未知'
  const receptionStatus = item.receptionStatus ? receptionStatusMap[item.receptionStatus] : '未知'
  return {
    id: item.testId, name: item.testName || '-', type: item.testTypeName || '-', batteryNo: '-', batteryGroup: '-',
    id: item.testId, name: item.testName || '-', type: item.testTypeName || '-', batteryNo: item.batteryNo || '-', batteryGroup: '-',
    batteryCount: Number(item.batteryCount) || 0, createdAt: item.createTime || '-', startedAt: item.startedAtUtc || '-',
    endedAt: item.endedAtUtc || '-', purpose: item.testPurpose || '-', owner: item.principalName || '-',
    remark: item.remark || '-', status, monitoringStatus, editable: item.monitoringStatus === 'NOT_MONITORED',
    remark: item.remark || '-', status, receptionStatus, editable: item.receptionStatus === 'NOT_STARTED',
  }
}
src/stores/tests.ts
@@ -1,7 +1,7 @@
import { ref } from 'vue'
export type TestStatus = '待测试' | '检测中' | '暂停' | '检测中(异常)' | '完成确认中' | '检测完成' | '已终止' | '未知'
export type MonitoringStatus = '未监测' | '等待设备启动' | '监测中' | '补采中' | '监测结束' | '已取消' | '未知'
export type ReceptionStatus = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' | '未知'
export interface TestRecord {
  id: number
@@ -17,7 +17,7 @@
  owner: string
  remark: string
  status: TestStatus
  monitoringStatus?: MonitoringStatus
  receptionStatus?: ReceptionStatus
  editable?: boolean
}
src/views/TestCreate.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import {
  createTest,
@@ -68,6 +68,7 @@
const monitorTimeoutMinutes = ref(30)
let batteryKey = 1
let thresholdKey = 1
let customChannelKey = 1
const form = reactive({
  name: '',
@@ -95,6 +96,17 @@
  { label: '检测柜返回项(待现场验证)', value: 'CABINET_RETURN', unit: 'V' as const, disabled: true },
]
const fixedSensorFields = ['temperature1', 'temperature2', 'temperature3', 'positive1', 'positive2', 'negative1', 'negative2'] as const
type SensorField = typeof fixedSensorFields[number]
const sensorDefinitions: Array<{ field: SensorField; code: string; name: string; required: boolean }> = [
  { field: 'temperature1', code: 'PRIMARY_TEMPERATURE', name: '主温度通道', required: true },
  { field: 'temperature2', code: 'SECONDARY_TEMPERATURE_2', name: '温度通道 2', required: false },
  { field: 'temperature3', code: 'SECONDARY_TEMPERATURE_3', name: '温度通道 3', required: false },
  { field: 'positive1', code: 'PRIMARY_POSITIVE_POTENTIAL', name: '正电位主通道', required: true },
  { field: 'positive2', code: 'SECONDARY_POSITIVE_POTENTIAL', name: '正电位通道 2', required: false },
  { field: 'negative1', code: 'PRIMARY_NEGATIVE_POTENTIAL', name: '负电位主通道', required: true },
  { field: 'negative2', code: 'SECONDARY_NEGATIVE_POTENTIAL', name: '负电位通道 2', required: false },
]
const sensorByCode = new Map(sensorDefinitions.map(item => [item.code, item]))
const customChannelColumns = ref<CustomChannelColumn[]>([])
const batteryGroupOptions = ref<BatteryGroupOption[]>([])
const cabinetOptions = computed(() => {
@@ -107,6 +119,7 @@
const collectorOptions = computed(() => (configurationOptions.value?.collectors || []).map(item => ({
  id: item.collectorId,
  label: item.collectorName || item.collectorCode,
  channelCount: item.channelCount,
})))
function createBatteryRow(batteryNo = '', groupId: number | null = null): BatteryRow {
@@ -163,6 +176,14 @@
  selectedBatteryNos.value = []
}
function toggleBatteryNo(batteryNo: string, selected: string | number | boolean) {
  if (selected === true) {
    if (!selectedBatteryNos.value.includes(batteryNo)) selectedBatteryNos.value.push(batteryNo)
    return
  }
  selectedBatteryNos.value = selectedBatteryNos.value.filter(item => item !== batteryNo)
}
function confirmAddBatteries() {
  if (!selectedBatteryGroup.value) {
    ElMessage.warning('请先选择电池组')
@@ -199,12 +220,54 @@
  return batteries.value.some(other => other.key !== item.key && other.cabinetChannel === channelId)
}
function collectorChannelsFor(collectorId: number | null) {
  const channelCount = collectorOptions.value.find(item => item.id === collectorId)?.channelCount || 0
  return Array.from({ length: channelCount }, (_, index) => `S-${String(index + 1).padStart(2, '0')}`)
}
function collectorFieldValues(item: BatteryRow) {
  return [
    ...fixedSensorFields.map(field => ({ key: field, value: item[field] })),
    ...customChannelColumns.value.map(column => ({ key: column.key, value: item.customChannels[column.key] || '' })),
  ]
}
function isCollectorChannelUsed(item: BatteryRow, fieldKey: string, channelCode: string) {
  return batteries.value.some(other => other.collector === item.collector
    && collectorFieldValues(other).some(field => field.value === channelCode
      && (other.key !== item.key || field.key !== fieldKey)))
}
function handleCabinetChange(item: BatteryRow) {
  item.cabinetChannel = null
}
function addChannelField() {
  ElMessage.info('采集器通道结构待现场验证')
function handleCollectorChange(item: BatteryRow) {
  fixedSensorFields.forEach(field => { item[field] = '' })
  Object.keys(item.customChannels).forEach(key => { item.customChannels[key] = '' })
}
async function addChannelField() {
  try {
    const { value } = await ElMessageBox.prompt('请输入测量项名称', '添加采集器测量项', {
      confirmButtonText: '添加', cancelButtonText: '取消', inputPattern: /^.{1,100}$/,
      inputErrorMessage: '测量项名称长度必须为1到100个字符',
    })
    const label = value.trim()
    if (!label) {
      ElMessage.warning('请输入测量项名称')
      return
    }
    if (customChannelColumns.value.some(item => item.label === label)) {
      ElMessage.warning('测量项名称不可重复')
      return
    }
    const key = `CUSTOM_${Date.now()}_${customChannelKey++}`
    customChannelColumns.value.push({ key, label })
    batteries.value.forEach(item => { item.customChannels[key] = '' })
  } catch {
    // 用户取消时保持当前配置。
  }
}
function applyOptions(options: TestConfigurationOptions) {
@@ -225,8 +288,8 @@
}
function applyDetail(detail: TestConfigurationDetail) {
  if (detail.monitoringStatus !== 'NOT_MONITORED') {
    throw new Error('该测试已经开始监测,不能修改配置')
  if (detail.receptionStatus !== 'NOT_STARTED') {
    throw new Error('该测试已经开始接收,不能修改配置')
  }
  form.name = detail.testName
  form.principalId = detail.principalId
@@ -236,6 +299,13 @@
  monitorTimeoutMinutes.value = detail.monitorTimeoutMinutes || 30
  preservedPlanSteps.value = detail.planSteps || []
  const customMappings = detail.batteries.flatMap(battery => battery.collectorChannels || [])
    .filter(mapping => !sensorByCode.has(mapping.measurementCode))
  customChannelColumns.value = [...new Map(customMappings.map(mapping => [mapping.measurementCode, {
    key: mapping.measurementCode,
    label: mapping.measurementName,
  }])).values()]
  batteries.value = detail.batteries.map(battery => {
    const row = createBatteryRow(battery.batteryNo, battery.groupId)
    const channel = configurationOptions.value?.channels.find(item => item.channelId === battery.binding.channelId)
@@ -244,6 +314,11 @@
    row.collector = battery.collectorId
    row.expectedBarcode = battery.binding.expectedBarcode
    row.preservedFields = battery.fields
    for (const mapping of battery.collectorChannels || []) {
      const sensor = sensorByCode.get(mapping.measurementCode)
      if (sensor) row[sensor.field] = mapping.channelCode
      else row.customChannels[mapping.measurementCode] = mapping.channelCode
    }
    return row
  })
  thresholds.value = detail.thresholds.map(item => ({
@@ -296,6 +371,7 @@
  const requiredFields: Array<[keyof BatteryRow, string]> = [
    ['batteryNo', '电池号'], ['groupId', '电池组'], ['cabinet', '检测柜'], ['cabinetChannel', '检测柜通道'],
    ['collector', '采集器'], ['temperature1', '主温度通道'], ['positive1', '正电位主通道'], ['negative1', '负电位主通道'],
  ]
  for (let index = 0; index < batteries.value.length; index += 1) {
    for (const [field, label] of requiredFields) {
@@ -308,6 +384,10 @@
  if (groupIds.size !== 1) return '同一测试只能选择一个电池组'
  const cabinetChannels = batteries.value.map(item => item.cabinetChannel)
  if (new Set(cabinetChannels).size !== cabinetChannels.length) return '同一检测柜通道不可重复绑定'
  const collectorChannels = batteries.value.flatMap(item => collectorFieldValues(item)
    .filter(field => field.value)
    .map(field => `${item.collector}:${field.value}`))
  if (new Set(collectorChannels).size !== collectorChannels.length) return '同一采集器通道不可重复映射'
  if (!thresholds.value.length) return '至少需要配置一项预警阈值'
  for (let index = 0; index < thresholds.value.length; index += 1) {
@@ -320,6 +400,24 @@
function buildBatteryFields(item: BatteryRow) {
  return item.preservedFields
}
function buildCollectorChannels(item: BatteryRow) {
  const fixed = sensorDefinitions
    .filter(sensor => item[sensor.field])
    .map(sensor => ({
      measurementCode: sensor.code,
      measurementName: sensor.name,
      channelCode: item[sensor.field],
    }))
  const custom = customChannelColumns.value
    .filter(column => item.customChannels[column.key])
    .map(column => ({
      measurementCode: column.key,
      measurementName: column.label,
      channelCode: item.customChannels[column.key],
    }))
  return [...fixed, ...custom]
}
async function submit() {
@@ -340,11 +438,12 @@
      batteries: batteries.value.map(item => ({
        groupId: item.groupId!,
        batteryNo: item.batteryNo.trim(),
        collectorId: item.collector,
        collectorId: item.collector!,
        binding: {
          channelId: item.cabinetChannel!,
          expectedBarcode: item.expectedBarcode,
        },
        collectorChannels: buildCollectorChannels(item),
        fields: buildBatteryFields(item),
      })),
      planSteps: preservedPlanSteps.value,
@@ -400,13 +499,13 @@
      </header>
      <div class="section-actions">
        <div><button class="primary-button" type="button" @click="openBatteryDialog"><el-icon><Plus /></el-icon>添加电池</button><button class="secondary-button" type="button" @click="addChannelField"><el-icon><Plus /></el-icon>添加项</button><button class="secondary-button danger" type="button" @click="removeBatteries"><el-icon><Delete /></el-icon>删除所选</button></div>
        <span>至少配置一块电池 · 采集器通道结构待现场验证</span>
        <span>采集器通道为第一版演示合同,真实拓扑待现场验证</span>
      </div>
      <div class="config-table-wrap">
        <table class="config-table battery-table" :style="{ minWidth: `${1700 + customChannelColumns.length * 150}px` }">
          <thead><tr>
            <th><el-checkbox v-model="allBatteriesSelected" aria-label="选择全部电池" /></th><th>电池号 *</th><th>检测柜 *</th><th>检测柜通道 *</th><th>采集器</th>
            <th>主温度通道</th><th>温度通道 2</th><th>温度通道 3</th><th>正电位主通道</th><th>正电位通道 2</th><th>负电位主通道</th><th>负电位通道 2</th>
            <th><el-checkbox v-model="allBatteriesSelected" aria-label="选择全部电池" /></th><th>电池号 *</th><th>检测柜 *</th><th>检测柜通道 *</th><th>采集器 *</th>
            <th>主温度通道 *</th><th>温度通道 2</th><th>温度通道 3</th><th>正电位主通道 *</th><th>正电位通道 2</th><th>负电位主通道 *</th><th>负电位通道 2</th>
            <th v-for="column in customChannelColumns" :key="column.key" class="custom-channel-header">{{ column.label }}</th>
          </tr></thead>
          <tbody>
@@ -415,12 +514,16 @@
              <td><el-input v-model="item.batteryNo" disabled placeholder="电池号" /></td>
              <td><el-select v-model="item.cabinet" placeholder="选择" @change="handleCabinetChange(item)"><el-option v-for="option in cabinetOptions" :key="option.id" :label="option.label" :value="option.id" /></el-select></td>
              <td><el-select v-model="item.cabinetChannel" clearable :disabled="!item.cabinet" :placeholder="item.cabinet ? '选择' : '请先选择检测柜'"><el-option v-for="option in channelsForCabinet(item.cabinet)" :key="option.channelId" :label="isCabinetChannelUsed(item, option.channelId) ? `${option.displayNo || `CH-${option.chlid}`}(已占用)` : option.displayNo || `CH-${option.chlid}`" :value="option.channelId" :disabled="isCabinetChannelUsed(item, option.channelId)" /></el-select></td>
              <td><el-select v-model="item.collector" clearable placeholder="选择(选填)"><el-option v-for="option in collectorOptions" :key="option.id" :label="option.label" :value="option.id" /></el-select></td>
              <td v-for="field in fixedSensorFields" :key="field">
                <el-select v-model="item[field]" disabled placeholder="待现场验证" />
              <td><el-select v-model="item.collector" clearable placeholder="选择" @change="handleCollectorChange(item)"><el-option v-for="option in collectorOptions" :key="option.id" :label="option.label" :value="option.id" /></el-select></td>
              <td v-for="sensor in sensorDefinitions" :key="sensor.field">
                <el-select v-model="item[sensor.field]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选择' : '请先选择采集器'">
                  <el-option v-for="channelCode in collectorChannelsFor(item.collector)" :key="channelCode" :label="isCollectorChannelUsed(item, sensor.field, channelCode) ? `${channelCode}(已映射)` : channelCode" :value="channelCode" :disabled="isCollectorChannelUsed(item, sensor.field, channelCode)" />
                </el-select>
              </td>
              <td v-for="column in customChannelColumns" :key="column.key">
                <el-select v-model="item.customChannels[column.key]" disabled placeholder="待现场验证" />
                <el-select v-model="item.customChannels[column.key]" clearable :disabled="!item.collector" :placeholder="item.collector ? '选择' : '请先选择采集器'">
                  <el-option v-for="channelCode in collectorChannelsFor(item.collector)" :key="channelCode" :label="isCollectorChannelUsed(item, column.key, channelCode) ? `${channelCode}(已映射)` : channelCode" :value="channelCode" :disabled="isCollectorChannelUsed(item, column.key, channelCode)" />
                </el-select>
              </td>
            </tr>
            <tr v-if="!batteries.length"><td :colspan="12 + customChannelColumns.length" class="empty-row">尚未配置电池,请点击“添加电池”</td></tr>
@@ -472,7 +575,7 @@
            <thead><tr><th><el-checkbox v-model="allGroupBatteriesSelected" :disabled="!availableGroupBatteryNos.length" aria-label="选择全部可用电池" /></th><th>电池组</th><th>电池名</th><th>状态</th></tr></thead>
            <tbody>
              <tr v-for="batteryNo in groupBatteryNos" :key="batteryNo" :class="{ added: isBatteryAdded(batteryNo) }">
                <td><el-checkbox v-model="selectedBatteryNos" :value="batteryNo" :disabled="isBatteryAdded(batteryNo)" :aria-label="`选择电池 ${batteryNo}`" /></td>
                <td><el-checkbox :model-value="selectedBatteryNos.includes(batteryNo)" :disabled="isBatteryAdded(batteryNo)" :aria-label="`选择电池 ${batteryNo}`" @change="toggleBatteryNo(batteryNo, $event)" /></td>
                <td>{{ selectedBatteryGroupName }}</td>
                <td>{{ batteryNo }}</td>
                <td><span :class="['battery-state', { used: isBatteryAdded(batteryNo) }]">{{ isBatteryAdded(batteryNo) ? '已添加' : '可添加' }}</span></td>
tests/e2e/test-required-device-bindings.spec.ts
New file
@@ -0,0 +1,219 @@
import { expect, test, type Locator, 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 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 selectOption(select: Locator, optionName: string) {
  const combobox = select.getByRole('combobox')
  await select.locator('.el-select__wrapper').click()
  const listboxId = await combobox.getAttribute('aria-controls')
  const option = select.page().locator(`#${listboxId}`).getByRole('option').filter({ hasText: optionName }).first()
  await expect(option).toBeVisible()
  await option.click()
}
async function registerDevice(
  page: Page,
  path: string,
  deviceCode: string,
  nameLabel: string,
  name: string,
) {
  await page.goto(path)
  await page.getByRole('button', { name: '选择设备' }).click()
  const candidate = page.locator('.device-table tbody tr').filter({ hasText: deviceCode })
  await expect(candidate).toBeVisible()
  await candidate.getByRole('button', { name: '选择' }).click()
  await page.locator('label').filter({ hasText: nameLabel }).locator('input').fill(name)
  if (nameLabel === '检测柜名称') {
    await page.locator('label').filter({ hasText: '规格/容量' }).locator('input').fill('120')
  }
  await page.getByRole('button', { name: '保存', exact: true }).click()
}
async function apiJson<T>(page: Page, path: string, init: RequestInit = {}) {
  return page.evaluate(async ({ requestPath, requestInit }) => {
    const token = localStorage.getItem('battery_detection_auth_token')
    const headers = new Headers(requestInit.headers)
    if (requestInit.body) headers.set('Content-Type', 'application/json')
    if (token) headers.set('Authorization', `Bearer ${token}`)
    const response = await fetch(`/dev-api${requestPath}`, { ...requestInit, headers })
    return response.json()
  }, { requestPath: path, requestInit: init }) as Promise<T>
}
test('测试创建保存必填双设备绑定、可选映射和字段快照', async ({ page }) => {
  const suffix = Date.now()
  const cabinetName = `Ticket07检测柜${suffix}`
  const collectorName = `Ticket07采集器${suffix}`
  const testTypeName = `Ticket07类型${suffix}`
  const fieldName = `额定容量${suffix}`
  const templateName = `Ticket07模板${suffix}`
  const groupName = `Ticket07电池组${suffix}`
  const batteryNo = `BAT-${suffix}`
  const testName = `Ticket07测试${suffix}`
  const dynamicMeasurement = `壳体压力${suffix}`
  await test.step('配置模拟器并登记检测柜和六通道采集器', async () => {
    await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`)
    page.once('dialog', dialog => dialog.accept())
    const reset = page.waitForResponse(response => response.url().endsWith('/dev/bts-simulator/reset') && response.ok())
    await page.getByRole('button', { name: '重置模拟器' }).click()
    await reset
    await page.locator('#new-collector-ip').fill('127.0.1.7')
    await page.locator('#new-collector-channel-count').fill('6')
    await page.locator('#add-collector').getByRole('button', { name: '添加采集器' }).click()
    await expect(page.getByRole('heading', { name: 'dev-sim 采集器 2' })).toBeVisible()
    await login(page)
    await registerDevice(page, '/device-cabinet/new', 'dev-sim-22-1', '检测柜名称', cabinetName)
    await expect(page).toHaveURL(/\/device-cabinet$/)
    await registerDevice(page, '/device-collector/new', 'dev-sim-collector-2', '采集器名称', collectorName)
    await expect(page).toHaveURL(/\/device-collector$/)
  })
  await test.step('通过正式页面创建测试类型、共享字段和电池组', async () => {
    await page.goto('/test-type/new')
    await page.getByPlaceholder('输入测试类型名称').fill(testTypeName)
    await page.getByRole('button', { name: '保存', exact: true }).click()
    await expect(page.locator('tbody tr').filter({ hasText: testTypeName })).toBeVisible()
    await page.goto('/battery-group/fields')
    await page.getByRole('button', { name: '新增字段' }).click()
    await page.locator('.field-dialog label').filter({ hasText: '字段名称' }).locator('input').fill(fieldName)
    await page.locator('.field-dialog label').filter({ hasText: '字段类型' }).locator('.el-select__wrapper').click()
    await page.getByRole('option', { name: '数字', exact: true }).click()
    await page.getByRole('button', { name: '保存', exact: true }).last().click()
    await expect(page.locator('tbody tr').filter({ hasText: fieldName })).toBeVisible()
    await page.goto('/battery-group/new')
    await page.getByRole('button', { name: '编辑当前模板' }).click()
    await page.locator('.field-checkbox-grid .el-checkbox').filter({ hasText: fieldName }).click()
    await page.getByRole('button', { name: '另存模板' }).click()
    await page.locator('.el-message-box__input input').fill(templateName)
    await page.getByRole('button', { name: '保存', exact: true }).last().click()
    await page.locator('label').filter({ hasText: '电池组名称' }).locator('input').fill(groupName)
    await page.locator('label').filter({ hasText: '型号' }).locator('input').fill('TICKET07')
    await page.locator('label').filter({ hasText: '目的' }).locator('textarea').fill('验证测试设备绑定')
    await page.locator('.battery-row input').first().fill(batteryNo)
    await page.getByPlaceholder(`输入${fieldName}`).fill('72')
    await page.getByRole('button', { name: '保存新建电池组', exact: true }).click()
    await expect(page.locator('tbody tr').filter({ hasText: groupName })).toBeVisible()
  })
  await test.step('缺少必填映射不能保存,完整映射可创建', async () => {
    await page.goto('/test-list/new')
    await page.getByPlaceholder('输入测试名称').fill(testName)
    await selectOption(page.locator('.parameter-grid label').filter({ hasText: '测试负责人' }).locator('.el-select'), '若依')
    await selectOption(page.locator('.parameter-grid label').filter({ hasText: '测试类型' }).locator('.el-select'), testTypeName)
    await page.getByPlaceholder('输入测试目的').fill('验证 Ticket07 必填双设备绑定')
    await page.getByRole('button', { name: '添加电池' }).click()
    await selectOption(page.locator('.battery-group-search .el-select'), groupName)
    await page.getByLabel(`选择电池 ${batteryNo}`).click()
    await page.locator('.battery-dialog').getByRole('button', { name: '提交', exact: true }).click()
    await expect(page.locator('.battery-dialog')).toBeHidden()
    const row = page.locator('.battery-table tbody tr').first()
    const selects = row.locator('.el-select')
    await selectOption(selects.nth(0), cabinetName)
    const channelCombobox = selects.nth(1).getByRole('combobox')
    await selects.nth(1).locator('.el-select__wrapper').click()
    const channelListboxId = await channelCombobox.getAttribute('aria-controls')
    const cabinetChannel = page.locator(`#${channelListboxId}`).getByRole('option').first()
    await expect(cabinetChannel).toBeVisible()
    await cabinetChannel.click()
    await selectOption(selects.nth(2), collectorName)
    await selectOption(selects.nth(3), 'S-01')
    await selectOption(selects.nth(6), 'S-02')
    await page.getByRole('button', { name: '提交测试', exact: true }).click()
    await expect(page.getByRole('alert').filter({ hasText: '第 1 行请选择负电位主通道' })).toBeVisible()
    await selectOption(selects.nth(4), 'S-04')
    await selectOption(selects.nth(8), 'S-03')
    await page.getByRole('button', { name: '添加项' }).click()
    await page.locator('.el-message-box__input input').fill(dynamicMeasurement)
    await page.getByRole('button', { name: '添加', exact: true }).click()
    await selectOption(row.locator('.el-select').last(), 'S-05')
    const thresholdRows = page.locator('.threshold-table tbody tr')
    await thresholdRows.nth(0).locator('input[type="number"]').nth(0).fill('0')
    await thresholdRows.nth(0).locator('input[type="number"]').nth(1).fill('80')
    await thresholdRows.nth(1).locator('input[type="number"]').nth(0).fill('2.5')
    await thresholdRows.nth(1).locator('input[type="number"]').nth(1).fill('4.3')
    await page.getByRole('button', { name: '提交测试', exact: true }).click()
    await expect(page).toHaveURL(/\/test-list$/)
    const listRow = page.locator('tbody tr').filter({ hasText: testName })
    await expect(listRow).toContainText(batteryNo)
    await expect(listRow).toContainText('待测试')
  })
  let testId = 0
  await test.step('编辑与刷新从后端恢复全部映射', async () => {
    const listRow = page.locator('tbody tr').filter({ hasText: testName })
    testId = Number(((await listRow.locator('.id-cell').textContent()) || '').replace('#', ''))
    await listRow.getByRole('button', { name: '编辑' }).click()
    await expect(page.getByPlaceholder('输入测试名称')).toHaveValue(testName)
    const row = page.locator('.battery-table tbody tr').first()
    await expect(page.locator('.battery-table .custom-channel-header')).toContainText(dynamicMeasurement)
    await expect(row.locator('.el-select').nth(3)).toContainText('S-01')
    await expect(row.locator('.el-select').nth(4)).toContainText('S-04')
    await expect(row.locator('.el-select').nth(6)).toContainText('S-02')
    await expect(row.locator('.el-select').nth(8)).toContainText('S-03')
    await expect(row.locator('.el-select').last()).toContainText('S-05')
    await page.reload()
    await expect(page.getByPlaceholder('输入测试名称')).toHaveValue(testName)
    await expect(page.locator('.battery-table tbody tr').first().locator('.el-select').last()).toContainText('S-05')
  })
  await test.step('后端拒绝重复映射并保留测试共享字段快照', async () => {
    const detail = await apiJson<{ code: number; data: Record<string, any> }>(page, `/system/test/${testId}`)
    expect(detail.code).toBe(200)
    expect(detail.data.fieldSnapshots).toEqual(expect.arrayContaining([
      expect.objectContaining({ fieldName, fieldValue: '72' }),
    ]))
    const duplicatePayload = {
      ...detail.data,
      testId: undefined,
      testName: `${testName}-重复映射`,
      batteries: detail.data.batteries.map((battery: Record<string, any>) => ({
        ...battery,
        collectorChannels: battery.collectorChannels.map((mapping: Record<string, any>, index: number) => ({
          ...mapping,
          channelCode: index === 1 ? battery.collectorChannels[0].channelCode : mapping.channelCode,
        })),
      })),
    }
    const rejected = await apiJson<{ code: number; msg: string }>(page, '/system/test', {
      method: 'POST', body: JSON.stringify(duplicatePayload),
    })
    expect(rejected.code).not.toBe(200)
    expect(rejected.msg).toContain('同一采集器通道不可重复映射')
    await page.goto('/battery-group')
    await page.locator('tbody tr').filter({ hasText: groupName }).getByRole('button', { name: '编辑' }).click()
    await page.getByPlaceholder(`输入${fieldName}`).fill('99')
    await page.getByRole('button', { name: '保存编辑电池组', exact: true }).click()
    const afterGroupChange = await apiJson<{ code: number; data: Record<string, any> }>(page, `/system/test/${testId}`)
    expect(afterGroupChange.data.fieldSnapshots).toEqual(expect.arrayContaining([
      expect.objectContaining({ fieldName, fieldValue: '72' }),
    ]))
  })
  const businessKeys = await page.evaluate(() => Object.keys(localStorage)
    .filter(key => key.includes('battery_detection_tests')))
  expect(businessKeys).toEqual([])
})