对齐测试删除和时间显示并补齐全链路验收
保留现有页面,开放全生命周期测试删除,适配业务毫秒时间并移除废止覆盖接口。扩展隔离验收,覆盖导入修订、当前字段、重启恢复、通道复用及数据库无痕删除。
前端构建、控制台契约脚本和端到端验收通过。
| | |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export type BatteryGroupFieldStatus = '启用' | '禁用' |
| | | export type BatteryGroupFieldType = '文本' | '数字' | '时间' | '备注' |
| | |
| | | fieldName: string | null |
| | | fieldType: string | null |
| | | createBy: string | null |
| | | createTime: string | null |
| | | createTime: number | null |
| | | status: string | null |
| | | } |
| | | |
| | |
| | | name: item.fieldName || '', |
| | | type: mapBatteryGroupFieldType(item.fieldType), |
| | | creator: item.createBy || '-', |
| | | createdAt: item.createTime || '', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | status: item.status === '1' ? '禁用' : '启用', |
| | | })) |
| | | } |
| | |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | import type { BatteryGroupPayload, GroupStatus } from './batteryGroupPayload' |
| | | |
| | | export type { GroupStatus } from './batteryGroupPayload' |
| | |
| | | purpose: string | null |
| | | status: string | null |
| | | createBy: string | null |
| | | createTime: string | null |
| | | createTime: number | null |
| | | batteryCount: number | null |
| | | remark?: string | null |
| | | z?: string | null |
| | |
| | | model: item.model || '-', |
| | | purpose: item.purpose || '-', |
| | | batteryCount: Number(item.batteryCount) || 0, |
| | | createdAt: item.createTime || '-', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | status: item.status === '1' ? '禁用' : '启用', |
| | | creator: item.createBy || '-', |
| | | templateFields: (item.templateFields || []) |
| | |
| | | CYCLE: { recordNo: '记录编号', batteryNo: '电池号', recordedAt: '设备时间', cycleNo: '循环序号', chargeCapacity: '充电容量(Ah)', dischargeCapacity: '放电容量(Ah)', efficiency: '充放电效率(%)', chargeEnergy: '充电能量(Wh)', dischargeEnergy: '放电能量(Wh)' }, |
| | | } satisfies Record<ConsoleDataType, Record<string, string>> |
| | | |
| | | export interface ConsoleDataOverride { |
| | | overrideId: number |
| | | testId: number |
| | | dataType: ConsoleDataType |
| | | stableIdentity: string |
| | | fieldValues: string | null |
| | | deleted: boolean |
| | | actionId: number |
| | | createdAtUtc: string |
| | | } |
| | | |
| | | export interface ConsoleDataActionPayload { |
| | | dataType: ConsoleDataType |
| | | operationType: ConsoleDataOperation |
| | |
| | | fieldValues: string | null |
| | | deleted: boolean |
| | | }> |
| | | btsSampleIds?: number[] |
| | | collectorSampleIds?: number[] |
| | | } |
| | | |
| | | interface OverrideResponse extends ApiResponse { data: ConsoleDataOverride[] } |
| | | interface ActionResponse extends ApiResponse { actionId: number } |
| | | |
| | | export async function getConsoleDataOverrides(testId: number, dataType: ConsoleDataType) { |
| | | return (await request<OverrideResponse>(`/system/test/${testId}/console-data/${dataType}/overrides`)).data || [] |
| | | } |
| | | |
| | | export async function submitConsoleDataAction(testId: number, payload: ConsoleDataActionPayload) { |
| | | return request<ActionResponse>(`/system/test/${testId}/console-data/actions`, { |
| | |
| | | DeviceCabinetRecord, |
| | | } from '@/stores/deviceCabinets' |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export interface DeviceCabinetListParams { |
| | | pageNum: number |
| | |
| | | currentTest: string | null |
| | | status: string | null |
| | | remark: string | null |
| | | createTime: string | null |
| | | createTime: number | null |
| | | } |
| | | |
| | | interface DetectionCabinetListResponse extends ApiResponse { |
| | |
| | | testStatus: testStatusMap[item.testStatus || ''] || '空闲', |
| | | currentTest: item.currentTest || '', |
| | | status: statusMap[item.status || ''] || '禁用', |
| | | createdAt: item.createTime || '', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | } |
| | | } |
| | | |
| | |
| | | DeviceCollectorRecord, |
| | | } from '@/stores/deviceCollectors' |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export interface DeviceCollectorListParams { |
| | | pageNum: number |
| | |
| | | currentTest: string | null |
| | | status: string | null |
| | | remark: string | null |
| | | createTime: string | null |
| | | createTime: number | null |
| | | } |
| | | |
| | | interface CollectorListResponse extends ApiResponse { |
| | |
| | | testStatus: testStatusMap[item.testStatus || ''] || '待检测', |
| | | currentTest: item.currentTest || '', |
| | | status: statusMap[item.status || ''] || '禁用', |
| | | createdAt: item.createTime || '', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | } |
| | | } |
| | | |
| | |
| | | import type { TestTypeRecord, TestTypeStatus } from '@/stores/testTypes' |
| | | import { request, type ApiResponse } from './request' |
| | | import { formatBusinessTime } from '@/utils/businessTime' |
| | | |
| | | export interface TestTypeListParams { |
| | | pageNum: number |
| | |
| | | testTypeName: string | null |
| | | status: string | null |
| | | createBy: string | null |
| | | createTime: string | null |
| | | createTime: number | null |
| | | remark: string | null |
| | | } |
| | | |
| | |
| | | return { |
| | | id: item.testTypeId, |
| | | name: item.testTypeName || '-', |
| | | createdAt: item.createTime || '', |
| | | createdAt: formatBusinessTime(item.createTime), |
| | | creator: item.createBy || '-', |
| | | remark: item.remark || '', |
| | | status: statusMap[item.status || ''] || '禁用', |
| | |
| | | |
| | | async function removeTest(item: TestRecord) { |
| | | try { |
| | | await ElMessageBox.confirm(`删除测试“${item.name}”后无法恢复,是否继续?`, '确认删除测试', { |
| | | await ElMessageBox.confirm(`删除测试“${item.name}”将一并删除全部测试数据、绑定、阈值、修订和异常记录,且无法恢复,是否继续?`, '确认删除测试', { |
| | | confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'danger-confirm', |
| | | }) |
| | | const deletionPage = currentPage.value |
| | |
| | | <div class="row-actions"> |
| | | <button type="button" @click="openConsole(item)">控制台</button> |
| | | <button v-if="item.editable" type="button" @click="editTest(item)">编辑</button> |
| | | <button v-if="item.editable" class="delete-action" type="button" :disabled="deletingTestId !== null" @click="removeTest(item)">{{ deletingTestId === item.id ? '删除中...' : '删除' }}</button> |
| | | <button class="delete-action" type="button" :disabled="deletingTestId !== null" @click="removeTest(item)">{{ deletingTestId === item.id ? '删除中...' : '删除' }}</button> |
| | | </div> |
| | | </td> |
| | | </tr> |
| New file |
| | |
| | | import { expect, test } from '@playwright/test' |
| | | import { Ticket04Stack } from './support/ticket04-stack' |
| | | |
| | | const stack = new Ticket04Stack() |
| | | test.beforeAll(async () => stack.start('druid')) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | | test('无模拟器成品包以无设备和 BTS 模式完成启动及重复迁移', async ({ request }) => { |
| | | expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | expect(stack.queryDatabase('SELECT COUNT(*) FROM t_device_adapter')).toBe('0') |
| | | const migrations = stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1') |
| | | expect(migrations).toBe('1,2,3,4,5') |
| | | await stack.restartBackend('druid,bts') |
| | | expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | expect(stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1')).toBe(migrations) |
| | | }) |
| | |
| | | |
| | | readonly backendUrl = `http://127.0.0.1:${apiPort}` |
| | | |
| | | async start() { |
| | | async start(profiles = 'druid,dev-simulator') { |
| | | await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable)) |
| | | this.docker = resolveDocker() |
| | | mkdirSync(artifactDir, { recursive: true }) |
| | |
| | | ]) |
| | | await this.waitForContainers() |
| | | this.buildBackend() |
| | | await this.startBackend() |
| | | await this.startBackend(profiles) |
| | | this.disableCaptchaInIsolatedDatabase() |
| | | } catch (error) { |
| | | await this.stop() |
| | |
| | | } |
| | | } |
| | | |
| | | async restartBackend() { |
| | | async restartBackend(profiles = 'druid,dev-simulator') { |
| | | await this.stopBackend() |
| | | await this.startBackend() |
| | | await this.startBackend(profiles) |
| | | } |
| | | |
| | | queryDatabase(sql: string) { |
| | | if (!/^SELECT\s/i.test(sql)) throw new Error('验收查询只允许 SELECT') |
| | | return this.dockerRun(['exec', this.mysqlContainer, 'mysql', '-uroot', `-p${mysqlPassword}`, |
| | | '--database=battery_detection', '--batch', '--skip-column-names', '-e', sql]).stdout.trim() |
| | | } |
| | | |
| | | redisCommand(...args: string[]) { |
| | |
| | | if (!existsSync(backendJar)) throw new Error(`未生成后端可执行包: ${backendJar}`) |
| | | } |
| | | |
| | | private async startBackend() { |
| | | private async startBackend(profiles = 'druid,dev-simulator') { |
| | | const logPath = resolve(artifactDir, 'backend.log') |
| | | const log = createWriteStream(logPath, { flags: 'a' }) |
| | | const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8` |
| | | const jdbcUrl = `jdbc:mysql://127.0.0.1:${mysqlPort}/battery_detection?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true` |
| | | this.backend = spawn('java', [ |
| | | '-jar', backendJar, |
| | | `--server.port=${apiPort}`, |
| | |
| | | `--spring.data.redis.port=${redisPort}`, |
| | | `--spring.data.redis.password=${redisPassword}`, |
| | | '--spring.data.redis.database=0', |
| | | '--spring.profiles.active=druid,dev-simulator', |
| | | `--spring.profiles.active=${profiles}`, |
| | | '--spring.devtools.restart.enabled=false', |
| | | `--battery.dev-simulator.port=${simulatorPort}`, |
| | | `--ruoyi.profile=${artifactDir}`, |
| | |
| | | await waitUntil(async () => { |
| | | if (this.backend?.exitCode !== null) throw new Error(`后端提前退出,详见 ${logPath}`) |
| | | try { |
| | | return (await fetch(`${this.backendUrl}/dev/bts-simulator/state`)).ok |
| | | return (await fetch(`${this.backendUrl}/captchaImage`)).ok |
| | | } catch { |
| | | return false |
| | | } |
| | |
| | | } |
| | | |
| | | interface PersistedRow { |
| | | id: string |
| | | recordNo: string |
| | | batteryNo: string |
| | | stableIdentity?: string |
| | | sequenceId?: number | null |
| | | voltage?: number | string | null |
| | | endVoltage?: number | string | null |
| | | dischargeCapacity?: number | string | null |
| | |
| | | await page.getByPlaceholder('输入电池号').fill(names.battery) |
| | | await page.getByRole('button', { name: '保存新建电池组', exact: true }).click() |
| | | await expect(page.locator('tbody tr').filter({ hasText: names.group })).toBeVisible() |
| | | expect(stack.queryDatabase("SELECT field_value FROM t_battery_group_relation_field relation JOIN t_battery_group_field definition ON definition.field_id=relation.battery_group_field_id WHERE definition.field_name='F' AND relation.del_flag=0")).toBe('2') |
| | | } |
| | | |
| | | async function createTest(page: Page, names: Record<string, string>) { |
| | |
| | | await expect(page.locator('.running-state')).toHaveText('暂停') |
| | | await expect(page.locator('.reception-state')).toHaveText('暂停接收') |
| | | await expect(page.locator('.cell-card .temperature')).toHaveText('-') |
| | | const pausedCount = (await persistedRows(page, testId, 'records')).length |
| | | const pausedRows = await persistedRows(page, testId, 'records') |
| | | const pausedCount = pausedRows.length |
| | | const pausedCabinetSequence = Math.max(0, ...pausedRows.map(row => Number(row.sequenceId) || 0)) |
| | | await page.waitForTimeout(1_500) |
| | | expect((await persistedRows(page, testId, 'records')).length).toBe(pausedCount) |
| | | |
| | |
| | | await page.getByRole('button', { name: '继续接收', exact: true }).last().click() |
| | | await expect(page.locator('.running-state')).toHaveText('检测中') |
| | | await expect.poll(async () => (await persistedRows(page, testId, 'records')).length).toBeGreaterThan(pausedCount) |
| | | await expect.poll(async () => Math.max(0, ...(await persistedRows(page, testId, 'records')).map(row => Number(row.sequenceId) || 0))) |
| | | .toBeGreaterThan(pausedCabinetSequence) |
| | | }) |
| | | |
| | | await test.step('三类当前有效数据可修订并形成统一修订记录', async () => { |
| | |
| | | await editFirstRow(page, '循环', '放电容量', '4.0000', 'Ticket06 循环数据修订') |
| | | |
| | | await page.getByRole('button', { name: '实时', exact: true }).click() |
| | | const recordsBeforeRemoval = (await persistedRows(page, testId, 'records')).length |
| | | const firstRowText = await page.locator('.console-data-panel tbody tr').first().innerText() |
| | | const recordToRemove = (await persistedRows(page, testId, 'records')).find(row => firstRowText.includes(row.recordNo)) |
| | | expect(recordToRemove).toBeDefined() |
| | | await page.locator('.console-data-panel tbody tr').first().getByRole('button', { name: '删除', exact: true }).click() |
| | | await expect(page.getByRole('dialog', { name: '移除数据' })).toBeVisible() |
| | | await expect(page.getByRole('dialog', { name: '物理删除数据' })).toBeVisible() |
| | | await page.locator('.el-message-box__input input').fill('Ticket06 删除当前有效数据') |
| | | await page.getByRole('button', { name: '确定', exact: true }).click() |
| | | await expect(page.getByRole('alert').filter({ hasText: '已从当前有效视图移除 1 条数据' })).toBeVisible() |
| | | await expect.poll(async () => (await persistedRows(page, testId, 'records')).length).toBe(recordsBeforeRemoval - 1) |
| | | await expect.poll(async () => (await persistedRows(page, testId, 'records')).some(row => row.id === recordToRemove?.id)).toBe(false) |
| | | |
| | | await page.getByRole('button', { name: '修订记录', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/data-revision/) |
| | |
| | | await page.getByRole('button', { name: '工步图表', exact: true }).click() |
| | | await expect(page.locator('.detail-meta input')).toHaveValue('Ticket06 最新测试详情') |
| | | expect((await persistedRows(page, testId, 'records')).length).toBeGreaterThan(0) |
| | | expect((await persistedRows(page, testId, 'steps')).some(row => Number(row.endVoltage) === 3.2222)).toBe(true) |
| | | expect((await persistedRows(page, testId, 'cycles')).some(row => Number(row.dischargeCapacity) === 4)).toBe(true) |
| | | }) |
| | | |
| | | await test.step('停止立即进入完成态,异常事实仍可人工关闭追溯', async () => { |
| | |
| | | }) |
| | | await page.getByRole('button', { name: '停止测试', exact: true }).click() |
| | | await page.getByRole('button', { name: '停止测试', exact: true }).last().click() |
| | | await expect(page.locator('.running-state')).toHaveText('检测完成') |
| | | await expect(page.locator('.running-state')).toHaveText('检测中') |
| | | await expect(page.getByRole('alert').filter({ hasText: '模拟停止失败' })).toBeVisible() |
| | | await expect(page.locator('.project-meta')).toContainText('检测中') |
| | | await page.unroute(`**/dev-api/system/test/${testId}/reception/end`) |
| | |
| | | expect(warningsAfterClose.rows.some(item => item.exceptionStatus === 'CLOSED')).toBe(true) |
| | | }) |
| | | |
| | | await test.step('三类导入直接落表,修改当前 F 后已有测试读取新值', async () => { |
| | | for (const [index, kind] of ['REALTIME', 'STEP', 'CYCLE'].entries()) { |
| | | const row = { recordNo: `IMPORT-${kind}`, batteryNo: names.battery, recordedAt: Date.now() + 3_600_000 + index, |
| | | cycleNo: 999, stepNo: 1, stepName: '人工核对', sequenceId: 1, voltage: 3.8, current: 1, |
| | | capacity: 2, energy: 4, endVoltage: 3.9, startVoltage: 3.1, stepDuration: 60, |
| | | chargeCapacity: 2, dischargeCapacity: 1.9, chargeEnergy: 4, dischargeEnergy: 3.8, efficiency: 95 } |
| | | const result = await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}/console-data/actions`, { |
| | | method: 'POST', body: JSON.stringify({ dataType: kind, operationType: 'IMPORT', scopeSummary: '导入一条核对记录', |
| | | identitySummary: '[]', changesSummary: JSON.stringify([row]), reason: 'Ticket13 导入验收' }), |
| | | }) |
| | | expect(result.code).toBe(200) |
| | | const table = `t_test_${kind.toLowerCase()}_record` |
| | | expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${testId} AND source_identity='IMPORT'`)).toBe('1') |
| | | const endpoint = kind === 'REALTIME' ? 'records' : kind === 'STEP' ? 'steps' : 'cycles' |
| | | expect((await persistedRows(page, testId, endpoint)).some(item => item.recordNo === row.recordNo)).toBe(true) |
| | | if (kind === 'REALTIME') expect(stack.queryDatabase(`SELECT cabinet_sequence_id FROM ${table} WHERE test_id=${testId} AND source_identity='IMPORT'`)).toBe('1') |
| | | } |
| | | const detail = await apiJson<ApiResult<{ batteries: Array<{ groupId: number }> }>>(page, `/system/test/${testId}`) |
| | | const groupId = detail.data.batteries[0]!.groupId |
| | | const group = await apiJson<ApiResult<Record<string, unknown> & { batteries: Array<{ batteryNo: string }> }>>(page, `/system/batteryGroup/${groupId}`) |
| | | const updated = await apiJson<ApiResult<unknown>>(page, '/system/batteryGroup', { method: 'PUT', |
| | | body: JSON.stringify({ ...group.data, f: '4', batteryNos: group.data.batteries.map(battery => battery.batteryNo) }) }) |
| | | expect(updated.code).toBe(200) |
| | | expect((await apiJson<ApiResult<{ f: string }>>(page, `/system/batteryGroup/${groupId}`)).data.f).toBe('4') |
| | | const current = await apiJson<ApiResult<{ batteries: Array<{ fields: Array<{ fieldName: string; fieldValue: string }> }> }>>(page, `/system/test/${testId}`) |
| | | expect(current.data.batteries[0]!.fields.find(field => field.fieldName === 'F')?.fieldValue).toBe('4') |
| | | await page.goto(`/test-list/${testId}/console?tab=project-chart`) |
| | | await expect(page.getByText(/电池组 F 不可用/)).toHaveCount(0) |
| | | }) |
| | | |
| | | await test.step('设备协议日志只包含发现和实时查询', async () => { |
| | | await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`) |
| | | const commands = await page.locator('#requests .request-command').allTextContents() |
| | | expect(commands.length).toBeGreaterThan(0) |
| | | expect(commands.every(command => command === 'getdevinfo' || command === 'inquire')).toBe(true) |
| | | }) |
| | | |
| | | await test.step('同通道跨测试复用,所有状态整体删除无痕且不影响其他事实', async () => { |
| | | await page.goto(`/test-list/${testId}/console`) |
| | | const original = await apiJson<ApiResult<Record<string, unknown>>>(page, `/system/test/${testId}`) |
| | | const deviceCounts = stack.queryDatabase('SELECT (SELECT COUNT(*) FROM t_detection_cabinet), (SELECT COUNT(*) FROM t_collector), (SELECT COUNT(*) FROM t_battery_group)') |
| | | const tables = ['t_test_realtime_record', 't_test_step_record', 't_test_cycle_record', 't_test_data_revision_action', |
| | | 't_test_warning_exception', 't_test_logic_step_mapping', 't_test_collector_channel', 't_test_channel_binding', |
| | | 't_test_warning_threshold', 't_test_battery', 't_test'] |
| | | for (const lifecycle of ['WAITING', 'RUNNING', 'PAUSED']) { |
| | | const created = await apiJson<ApiResult<{ testId: number }>>(page, '/system/test', { |
| | | method: 'POST', body: JSON.stringify({ ...original.data, testId: null, testName: `${names.test}-${lifecycle}` }), |
| | | }) |
| | | expect(created.code).toBe(200) |
| | | const id = created.data.testId |
| | | if (lifecycle !== 'WAITING') { |
| | | expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}/reception/start`, { method: 'POST' })).code).toBe(200) |
| | | await expect.poll(async () => (await persistedRows(page, id, 'records')).length).toBeGreaterThan(0) |
| | | } |
| | | if (lifecycle === 'PAUSED') expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}/reception/pause`, { method: 'POST' })).code).toBe(200) |
| | | expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${id}`, { method: 'DELETE' })).code).toBe(200) |
| | | for (const table of tables) expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${id}`)).toBe('0') |
| | | expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}`)).code).toBe(200) |
| | | } |
| | | expect((await apiJson<ApiResult<unknown>>(page, `/system/test/${testId}`, { method: 'DELETE' })).code).toBe(200) |
| | | for (const table of tables) expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=${testId}`)).toBe('0') |
| | | expect(stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log WHERE business_type=3 AND oper_url LIKE \'%/system/test/%\'')).toBe('0') |
| | | expect(stack.queryDatabase('SELECT (SELECT COUNT(*) FROM t_detection_cabinet), (SELECT COUNT(*) FROM t_collector), (SELECT COUNT(*) FROM t_battery_group)')).toBe(deviceCounts) |
| | | }) |
| | | |
| | | await test.step('无设备模式和真实 BTS 装配均可启动', async () => { |
| | | await stack.restartBackend('druid') |
| | | expect((await page.request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | await stack.restartBackend('druid,bts') |
| | | expect((await page.request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | }) |
| | | }) |