实现测试设备必填配置
测试创建和编辑页接入采集器通道必填、可选项与动态测量项。
补充后端事实回读、重复映射校验和 Ticket07 浏览器测试。
| | |
| | | "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", |
| | |
| | | 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 |
| | |
| | | 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 }> |
| | | } |
| | | |
| | |
| | | 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 { |
| | |
| | | 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 { |
| | |
| | | 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 |
| | |
| | | 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', |
| | |
| | | |
| | | 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', |
| | | } |
| | | } |
| | | |
| | |
| | | import { ref } from 'vue' |
| | | |
| | | export type TestStatus = '待测试' | '检测中' | '暂停' | '检测中(异常)' | '完成确认中' | '检测完成' | '已终止' | '未知' |
| | | export type MonitoringStatus = '未监测' | '等待设备启动' | '监测中' | '补采中' | '监测结束' | '已取消' | '未知' |
| | | export type ReceptionStatus = '未开始接收' | '接收中' | '暂停接收' | '已结束接收' | '未知' |
| | | |
| | | export interface TestRecord { |
| | | id: number |
| | |
| | | owner: string |
| | | remark: string |
| | | status: TestStatus |
| | | monitoringStatus?: MonitoringStatus |
| | | receptionStatus?: ReceptionStatus |
| | | editable?: boolean |
| | | } |
| | | |
| | |
| | | <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, |
| | |
| | | const monitorTimeoutMinutes = ref(30) |
| | | let batteryKey = 1 |
| | | let thresholdKey = 1 |
| | | let customChannelKey = 1 |
| | | |
| | | const form = reactive({ |
| | | name: '', |
| | |
| | | { 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(() => { |
| | |
| | | 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 { |
| | |
| | | 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('请先选择电池组') |
| | |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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 => ({ |
| | |
| | | |
| | | 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) { |
| | |
| | | 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) { |
| | |
| | | |
| | | 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() { |
| | |
| | | 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, |
| | |
| | | </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> |
| | |
| | | <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> |
| | |
| | | <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> |
| New file |
| | |
| | | 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([]) |
| | | }) |