yeg
2026-09-08 eb02e33a51ceb129eaa9e8a7ba72ca65854f9053
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
import { expect, test, type Locator, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
 
const stack = new Ticket04Stack()
 
interface ApiResult<T> {
  code: number
  data: T
}
 
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
}
 
interface WarningRecord {
  exceptionId: number
  occurrenceCount: number
  exceptionStatus: 'OPEN' | 'CLOSED'
  ruleSnapshot: string
}
 
test.use({ actionTimeout: 25_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 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>
}
 
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 selectFirstOption(select: Locator) {
  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').first()
  await expect(option).toBeVisible()
  await option.click()
}
 
async function registerDevice(page: Page, path: string, code: string, label: string, name: string) {
  await page.goto(path)
  await page.getByRole('button', { name: '选择设备' }).click()
  const candidate = page.locator('.device-table tbody tr').filter({ hasText: code })
  await expect(candidate).toBeVisible()
  await candidate.getByRole('button', { name: '选择' }).click()
  await page.locator('label').filter({ hasText: label }).locator('input').fill(name)
  if (label === '检测柜名称') {
    await page.locator('label').filter({ hasText: '规格/容量' }).locator('input').fill('120')
  }
  await page.getByRole('button', { name: '保存', exact: true }).click()
}
 
async function configureSimulator(page: Page) {
  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 expect(page.getByRole('heading', { name: '检测柜 1' })).toBeVisible()
  // ADR-0048 的当前参数由操作员配置,模拟器不会自动播放或推进循环。
  const configured = await page.request.put(`${stack.backendUrl}/dev/bts-simulator/devices/1/channels/1/configuration`, {
    data: { voltageV: 3.7, currentA: 1.2, capacityAh: 2.5, energyWh: 9.25,
      cycleNo: 1, stepNo: 1, plannedCycleCount: 3, totalTimeSeconds: 120, relativeTimeSeconds: 60,
      steps: [{ name: '恒流充电', stepType: 'cc', durationSeconds: 600, voltageV: 4.2 }] },
  })
  expect(configured.ok()).toBe(true)
  const working = await page.request.put(`${stack.backendUrl}/dev/bts-simulator/devices/1/channels/1`, {
    data: { online: true, workStatus: 'WORKING' },
  })
  expect(working.ok()).toBe(true)
 
  const collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) })
  await collector.locator('tbody tr').filter({ has: page.getByText('S-01', { exact: true }) })
    .getByRole('button', { name: '编辑测量值', exact: true }).click()
  const editor = page.getByRole('dialog', { name: '采集器 1 · S-01 测量值', exact: true })
  for (const [index, value] of ['48.5', '3.812', '-0.116'].entries())
    await editor.getByLabel('数值', { exact: true }).nth(index).fill(value)
  const saved = page.waitForResponse(response => response.request().method() === 'PUT'
    && response.url().endsWith('/dev/bts-simulator/collectors/1/channels/S-01') && response.ok())
  await editor.getByRole('button', { name: '保存通道配置', exact: true }).click()
  await saved
  await expect(editor).not.toBeVisible()
  await expect(collector.locator('tbody tr').filter({ has: page.getByText('S-01', { exact: true }) })).toContainText('温度 48.5 ℃')
}
 
async function createPrerequisites(page: Page, names: Record<string, string>) {
  await registerDevice(page, '/device-cabinet/new', 'dev-sim-22-1', '检测柜名称', names.cabinet)
  await expect(page).toHaveURL(/\/device-cabinet$/)
  await registerDevice(page, '/device-collector/new', 'dev-sim-collector-1', '采集器名称', names.collector)
  await expect(page).toHaveURL(/\/device-collector$/)
 
  await page.goto('/test-type/new')
  await page.getByPlaceholder('输入测试类型名称').fill(names.testType)
  await page.getByRole('button', { name: '保存', exact: true }).click()
  await expect(page.locator('tbody tr').filter({ hasText: names.testType })).toBeVisible()
 
  await page.goto('/battery-group/new')
  await page.getByPlaceholder('输入电池组名称').fill(names.group)
  await page.getByPlaceholder('输入样品型号').fill('TICKET06')
  await page.getByPlaceholder('输入F').fill('2')
  await page.getByPlaceholder('输入目的').fill('测试管理模块整体验收')
  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 page.goto('/test-list/new')
  await page.getByPlaceholder('输入测试名称').fill(names.test)
  await selectOption(page.locator('.parameter-grid label').filter({ hasText: '测试负责人' }).locator('.el-select'), '若依')
  await selectOption(page.locator('.parameter-grid label').filter({ hasText: '测试类型' }).locator('.el-select'), names.testType)
  await page.getByPlaceholder('输入测试目的').fill('验证完整观察、修订、图表和预警异常流程')
 
  await page.getByRole('button', { name: '添加电池' }).click()
  await selectOption(page.locator('.battery-group-search .el-select'), names.group)
  await page.getByLabel(`选择电池 ${names.battery}`).click()
  await page.locator('.battery-dialog').getByRole('button', { name: '提交', exact: true }).click()
 
  const row = page.locator('.battery-table tbody tr').first()
  const selects = row.locator('.el-select')
  await selectOption(selects.nth(0), names.cabinet)
  await selectFirstOption(selects.nth(1))
  await selectOption(selects.nth(2), names.collector)
  await selectOption(selects.nth(3), 'S-01')
  await selectOption(selects.nth(6), 'S-02')
  await selectOption(selects.nth(8), 'S-03')
 
  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('20')
  await thresholdRows.nth(1).locator('input[type="number"]').nth(0).fill('0')
  await thresholdRows.nth(1).locator('input[type="number"]').nth(1).fill('10')
  await page.getByRole('button', { name: '提交测试', exact: true }).click()
  await expect(page).toHaveURL(/\/test-list$/)
 
  const listRow = page.locator('tbody tr').filter({ hasText: names.test })
  await expect(listRow).toContainText('待测试')
  return Number(((await listRow.locator('.id-cell').textContent()) || '').replace('#', ''))
}
 
async function persistedRows(page: Page, testId: number, kind: 'records' | 'steps' | 'cycles') {
  const result = await apiJson<ApiResult<PersistedRow[]>>(page, `/system/test/${testId}/data/${kind}`)
  expect(result.code).toBe(200)
  return result.data || []
}
 
async function editFirstRow(page: Page, tabName: '实时' | '工步' | '循环', fieldLabel: string, value: string, reason: string) {
  await page.getByRole('button', { name: tabName, exact: true }).click()
  const firstRow = page.locator('.console-data-panel tbody tr').first()
  await expect(firstRow).toBeVisible()
  await firstRow.getByRole('button', { name: '编辑', exact: true }).click()
  const dialog = page.getByRole('dialog', { name: '编辑数据' })
  await dialog.locator('label').filter({ hasText: fieldLabel }).locator('input').fill(value)
  await dialog.getByPlaceholder('请输入数据修改原因,提交后将写入修订记录').fill(reason)
  await dialog.getByRole('button', { name: '提交修改', exact: true }).click()
  await expect(dialog).not.toBeVisible()
  await expect(page.getByRole('alert').filter({ hasText: '已提交 1 条数据修改' }).last()).toBeVisible()
}
 
test('测试管理模块完整观察、修订、图表和预警异常流程可恢复', async ({ page }) => {
  const suffix = Date.now()
  const names = {
    cabinet: `整体验收检测柜${suffix}`,
    collector: `整体验收采集器${suffix}`,
    testType: `整体验收类型${suffix}`,
    group: `整体验收电池组${suffix}`,
    battery: `BAT-${suffix}`,
    test: `整体验收测试${suffix}`,
  }
 
  await test.step('使用开发模拟器和正式页面创建完整测试配置', async () => {
    await configureSimulator(page)
    await login(page)
    await createPrerequisites(page, names)
  })
  const testId = await createTest(page, names)
 
  await test.step('开始、暂停和继续只改变系统接收状态', async () => {
    await page.goto(`/test-list/${testId}/console`)
    await page.getByRole('button', { name: '开始接收', exact: true }).click()
    await expect(page.getByText('已开始接收,未向设备发送控制命令', { exact: true })).toBeVisible()
    await expect(page.locator('.running-state')).toHaveText('检测中')
    await expect(page.locator('.reception-state')).toHaveText('接收中')
    await expect.poll(async () => (await persistedRows(page, testId, 'records')).length, { timeout: 90_000 }).toBeGreaterThan(1)
    await expect(page.locator('.cell-card .temperature')).not.toHaveText('-')
    await expect(page.locator('.alarm-list')).toContainText('采集器 · 温度超限')
    await expect(page.locator('.alarm-list')).toContainText('当前值:48.5 ℃ · 预警阈值:0 ~ 20 ℃')
 
    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('.reception-state')).toHaveText('暂停接收')
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
    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 }).click()
    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, { timeout: 90_000 }).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('重新登录、后端重启和 SSE 重连后恢复状态与事实', async () => {
    await page.getByRole('button', { name: '工步图表', exact: true }).click()
    await page.locator('.detail-meta input').fill('Ticket06 最新测试详情')
    await page.getByRole('button', { name: '保存详情', exact: true }).click()
    await expect(page.getByText('测试详情已保存', { exact: true })).toBeVisible()
    await page.getByRole('button', { name: '退出当前账号' }).click()
    await page.getByRole('button', { name: '退出', exact: true }).click()
    await login(page)
    await page.goto(`/test-list/${testId}/console`)
    await expect(page.locator('.running-state')).toHaveText('检测中')
 
    await stack.restartBackend()
    await expect.poll(async () => {
      try {
        const result = await apiJson<ApiResult<{ testStatus: string }>>(page, `/system/test/${testId}/live-state`)
        return result.data?.testStatus
      } catch {
        return ''
      }
    }, { timeout: 90_000 }).toBe('RUNNING')
    await expect(page.locator('.running-state')).toHaveText('检测中')
    await page.reload()
    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)
  })
 
  await test.step('停止立即进入完成态,异常事实仍可人工关闭追溯', async () => {
    const warningsBeforeStop = await apiJson<{ code: number; rows: WarningRecord[] }>(page,
      `/system/test-warning-exceptions/list?testId=${testId}&status=OPEN`)
    expect(warningsBeforeStop.rows.length).toBeGreaterThan(0)
    expect(warningsBeforeStop.rows[0].occurrenceCount).toBeGreaterThan(0)
    expect(JSON.parse(warningsBeforeStop.rows[0].ruleSnapshot).maxValue).toBe(20)
 
    await page.getByRole('button', { name: '实时监控', exact: true }).click()
    await page.route(`**/dev-api/system/test/${testId}/reception/end`, async route => {
      await new Promise(resolve => setTimeout(resolve, 700))
      await route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ code: 500, msg: '模拟停止失败' }) })
    })
    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.getByRole('alert').filter({ hasText: '模拟停止失败' })).toBeVisible()
    await expect(page.locator('.project-meta')).toContainText('检测中')
    await page.unroute(`**/dev-api/system/test/${testId}/reception/end`)
 
    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('.reception-state')).toHaveText('已结束接收')
    await expect(page.locator('.alarm-empty')).toContainText('暂无异常')
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
 
    const stoppedCount = (await persistedRows(page, testId, 'records')).length
    await page.waitForTimeout(1_500)
    expect((await persistedRows(page, testId, 'records')).length).toBe(stoppedCount)
 
    await page.goto('/exception')
    const exceptionRow = page.locator('tbody tr').filter({ hasText: names.test }).first()
    await expect(exceptionRow).toContainText('待处理')
    await exceptionRow.getByRole('button', { name: '处理', exact: true }).click()
    await page.locator('.el-message-box__input textarea').fill('Ticket06 人工确认并关闭')
    await page.getByRole('button', { name: '完成处理', exact: true }).click()
    await expect(exceptionRow).toContainText('已处理')
 
    const warningsAfterClose = await apiJson<{ code: number; rows: WarningRecord[] }>(page,
      `/system/test-warning-exceptions/list?testId=${testId}`)
    expect(warningsAfterClose.rows.some(item => item.exceptionStatus === 'CLOSED')).toBe(true)
  })
 
  await test.step('三类当前有效数据可修订并形成统一修订记录', async () => {
    // 模拟器没有工步或循环历史;检测完成后才通过正式导入准备供修订的事实。
    await page.goto(`/test-list/${testId}/console`)
    for (const kind of ['STEP', 'CYCLE']) {
      const row = { recordNo: `INITIAL-${kind}`, batteryNo: names.battery, recordedAt: Date.now(),
        cycleNo: 1, stepNo: 1, stepName: '恒流充电', sequenceId: 1,
        endVoltage: 4.2, startVoltage: 3.1, stepDuration: 60,
        chargeCapacity: 2, dischargeCapacity: 1.9, chargeEnergy: 4, dischargeEnergy: 3.8, efficiency: 95 }
      const imported = 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: `准备${kind}修订事实` }),
      })
      expect(imported.code).toBe(200)
      expect((await persistedRows(page, testId, kind === 'STEP' ? 'steps' : 'cycles')).some(item => item.recordNo === row.recordNo)).toBe(true)
    }
 
    await page.getByRole('button', { name: '数据列表', exact: true }).click()
    const download = page.waitForEvent('download')
    await page.getByRole('button', { name: '下载', exact: true }).click()
    expect((await download).suggestedFilename()).toMatch(/-realtime\.csv$/)
    await editFirstRow(page, '实时', '电压', '3.3333', 'Ticket06 实时数据修订')
    await editFirstRow(page, '工步', '终止电压', '3.2222', 'Ticket06 工步数据修订')
    await editFirstRow(page, '循环', '放电容量', '4000.0000', 'Ticket06 循环数据修订')
 
    await page.getByRole('button', { name: '实时', exact: true }).click()
    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 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')).some(row => row.id === recordToRemove?.id)).toBe(false)
 
    await page.getByRole('button', { name: '修订记录', exact: true }).click()
    await expect(page).toHaveURL(/\/data-revision/)
    await page.goto(`/data-revision?testId=${testId}`)
    const revisionTable = page.locator('.result-panel tbody')
    await expect(revisionTable.locator('tr')).toHaveCount(6)
    await expect(revisionTable).toContainText('准备STEP修订事实')
    await expect(revisionTable).toContainText('准备CYCLE修订事实')
    await expect(revisionTable).toContainText('Ticket06 实时数据修订')
    await expect(revisionTable).toContainText('Ticket06 工步数据修订')
    await expect(revisionTable).toContainText('Ticket06 循环数据修订')
    await expect(revisionTable).toContainText('Ticket06 删除当前有效数据')
    await page.goto(`/test-list/${testId}/console?tab=data-list`)
  })
 
  await test.step('工步详情、具体循环图表和项目图表从后端事实恢复', async () => {
    await page.getByRole('button', { name: '工步图表', exact: true }).click()
    await page.locator('.detail-meta input').fill('Ticket06 最新测试详情')
    await page.getByRole('button', { name: '保存详情', exact: true }).click()
    await expect(page.getByText('测试详情已保存', { exact: true })).toBeVisible()
    await expect(page.locator('.data-select-panel')).not.toContainText('全部循环')
    await expect(page.locator('.metric-chart canvas')).toHaveCount(5)
 
    await page.reload()
    await page.getByRole('button', { name: '工步图表', exact: true }).click()
    await expect(page.locator('.detail-meta input')).toHaveValue('Ticket06 最新测试详情')
 
    await page.getByRole('button', { name: '项目图表', exact: true }).click()
    await expect(page.getByText('最终电压1', { exact: true })).toBeVisible()
    await expect(page.getByText('最终电压2', { exact: true })).toBeVisible()
    await expect(page.getByText('每循环放电末容量 / F', { exact: true })).toBeVisible()
    await expect(page.locator('.project-chart canvas')).toHaveCount(3)
    await expect(page.getByText(/电池组 F 不可用/)).toHaveCount(0)
  })
 
  await test.step('完成态重启后保留详情和三类修订事实', async () => {
    await stack.restartBackend()
    await page.goto(`/test-list/${testId}/console`)
    await expect(page.locator('.running-state')).toHaveText('检测完成')
    await expect(page.locator('.reception-state')).toHaveText('已结束接收')
    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('三类导入直接落表,修改当前 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_record_id IN ('INITIAL-${kind}', '${row.recordNo}')`)).toBe(kind === 'REALTIME' ? '1' : '2')
      // 正式导入使用该电池绑定的完整通道身份,IMPORT 只是修订动作类型。
      expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} r JOIN t_test_channel_binding b ON b.binding_id=r.binding_id
        JOIN t_detection_channel c ON c.channel_id=b.channel_id WHERE r.test_id=${testId} AND r.source_record_id='${row.recordNo}'
        AND r.source_identity=CONCAT(c.adapter_code,':',c.devtype,':',c.devid,':',c.subdevid,':',c.chlid)`)).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_record_id='${row.recordNo}'`)).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`)
    await expect(page.locator('#requests .request-command').first()).toBeVisible()
    const commands = await page.locator('#requests .request-command').allTextContents()
    expect(commands.length).toBeGreaterThan(0)
    expect(commands.every(command => ['discover', 'observe', 'cabinet-realtime', 'collector-realtime', 'getdevinfo', 'inquire'].includes(command))).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)
  })
})