yeg
2026-09-06 99ec80408536b4e7123982dc05adeb165e49ad72
增加模拟器与历史导入隔离整体验收

复用隔离环境验证真实分钟采样、离线空档、实时预警与历史导入隔离。通过甲方工作簿核对三表刷新、重新登录、后端重启和重复导入,补充运行说明与验收证据。
1个文件已添加
2个文件已修改
333 ■■■■■ 已修改文件
README.md 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/simulator-import-acceptance.spec.ts 322 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md
@@ -40,3 +40,13 @@
```
测试默认使用端口 `15174`、`18001`、`63306` 和 `56379`,端口已占用时会直接失败而不复用现有服务。可通过 `TICKET04_WEB_PORT`、`TICKET04_API_PORT`、`TICKET04_MYSQL_PORT`、`TICKET04_REDIS_PORT` 覆盖;镜像仓库可通过 `TICKET04_MYSQL_IMAGE` 和 `TICKET04_REDIS_IMAGE` 覆盖。
模拟器与历史导入 Ticket05 的完整验收复用同一隔离环境,还使用模拟器端口 `15021`(可由 `TICKET04_SIMULATOR_PORT` 覆盖):
```powershell
npm run test:e2e:simulator-import-acceptance
```
运行前需安装 Chrome、Java/Maven,并提供可用的 Docker 或 WSL Docker。默认读取根工作区 `outputs/ticket03-excel/277#(5)(1)_补充三项测试值.xls`;可用 `PARTY_EXCEL` 指定同一甲方验收样例的副本路径,样例应包含 7605/129/33 行实时、工步、循环数据。
流程通过真实页面登记设备、导入文件,使用真实后端与独立 MySQL/Redis,按墙上时间等待分钟采样和离线空档,验证 5 秒预警、三表刷新、退出重登、后端重启、重复导入及数据流隔离。测试约需数分钟,不使用倍速或历史补采;结束后清理临时服务和容器。截图与验收事实在 `test-results`,后端日志在 `tests/e2e/.artifacts/backend.log`;该验证不证明真实 BTS 或采集器协议兼容性。
package.json
@@ -15,6 +15,7 @@
    "test:e2e:ticket10": "playwright test tests/e2e/pause-resume-reception.spec.ts",
    "test:e2e:ticket20": "playwright test tests/e2e/remove-drifted-result-query.spec.ts",
    "test:e2e:test-management-acceptance": "playwright test tests/e2e/test-management-acceptance.spec.ts",
    "test:e2e:simulator-import-acceptance": "playwright test tests/e2e/simulator-import-acceptance.spec.ts",
    "test:contract:ticket09": "node tests/test-console-live.contract.test.mjs",
    "test:contract:ticket02": "node tests/test-console-step-chart.contract.test.mjs"
  },
tests/e2e/simulator-import-acceptance.spec.ts
New file
@@ -0,0 +1,322 @@
import { expect, test, type Page } from '@playwright/test'
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import type { TestConfigurationDetail, TestConfigurationOptions } from '../../src/api/tests'
import type { DataRevisionAction } from '../../src/api/dataRevisions'
import { Ticket04Stack } from './support/ticket04-stack'
const stack = new Ticket04Stack()
const partyExcel = resolve(process.env.PARTY_EXCEL
  || '../outputs/ticket03-excel/277#(5)(1)_补充三项测试值.xls')
const batteryNo = 'T05-277'
const observedCommands = new Set<string>()
const readonlyCommands = ['discover', 'observe', 'cabinet-realtime', 'collector-realtime', 'getdevinfo', 'inquire']
const configuration = {
  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 }],
}
interface DataRow {
  recordNo: string
  deviceTime: number
  serverReceivedAt: number
  voltage: number | null
  temperature: number | null
  positivePotential: number | null
  negativePotential: number | null
  [key: string]: unknown
}
interface Warning {
  exceptionId: number
  exceptionType: string
  occurrenceCount: number
  [key: string]: unknown
}
interface SimulatorState {
  softwareAccessible: boolean
  devices: Array<{ deviceId: number; online: boolean; channels: Array<{ workStatus: string; configuration: typeof configuration }> }>
  collectors: Array<{ online: boolean; channels: Array<{ metrics: Array<{ metricCode: string; value: number }> }> }>
  recentRequests: Array<{ command: string; receivedAt: number | string }>
}
test.use({ actionTimeout: 25_000 })
test.beforeAll(async () => {
  expect(existsSync(partyExcel), '需提供 Ticket03 甲方兼容测试副本,可用 PARTY_EXCEL 指定路径').toBe(true)
  await 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 api<T>(page: Page, path: string, method = 'GET', data?: unknown): Promise<T> {
  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
  const response = await page.request.fetch(`${stack.backendUrl}${path}`, {
    method, data, headers: { Authorization: `Bearer ${token}` },
  })
  expect(response.ok(), path).toBe(true)
  const body = await response.json()
  expect(body.code, `${path}: ${body.msg || ''}`).toBe(200)
  return body
}
async function simulator(page: Page, path = '/state', data?: unknown): Promise<SimulatorState> {
  const response = await page.request.fetch(`${stack.backendUrl}/dev/bts-simulator${path}`, {
    method: data === undefined ? 'GET' : 'PUT', data,
  })
  expect(response.ok(), path).toBe(true)
  const state: SimulatorState = await response.json()
  for (const item of state.recentRequests) {
    observedCommands.add(item.command)
    expect(readonlyCommands, '最近请求只能包含发现、状态与实时观测').toContain(item.command)
  }
  return state
}
async function rows(page: Page, id: number, kind = 'records') {
  return (await api<{ data: DataRow[] }>(page, `/system/test/${id}/data/${kind}`)).data
}
async function warnings(page: Page, id: number) {
  return (await api<{ rows: Warning[] }>(page, `/system/test-warning-exceptions/list?testId=${id}&pageSize=100`)).rows
}
async function register(page: Page, kind: 'cabinet' | 'collector', code: string, label: string) {
  await page.goto(`/device-${kind}/new`)
  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: '选择', exact: true }).click()
  await page.locator('label').filter({ hasText: `${label}名称` }).locator('input').fill(`Ticket05${label}`)
  if (kind === 'cabinet') await page.locator('label').filter({ hasText: '规格/容量' }).locator('input').fill('120')
  await page.getByRole('button', { name: '保存', exact: true }).click()
  await expect(page).toHaveURL(new RegExp(`/device-${kind}$`))
}
async function createTest(page: Page) {
  await register(page, 'cabinet', 'dev-sim-22-1', '检测柜')
  await register(page, 'collector', 'dev-sim-collector-1', '采集器')
  await page.goto('/test-type/new')
  await page.getByPlaceholder('输入测试类型名称').fill('Ticket05导入验收')
  await page.getByRole('button', { name: '保存', exact: true }).click()
  await expect(page).toHaveURL(/\/test-type$/)
  await page.goto('/battery-group/new')
  await page.getByPlaceholder('输入电池组名称').fill('Ticket05电池组')
  await page.getByPlaceholder('输入样品型号').fill('277')
  await page.getByPlaceholder('输入F').fill('2')
  await page.getByPlaceholder('输入目的').fill('模拟器与历史导入整体验收')
  await page.getByPlaceholder('输入电池号').fill(batteryNo)
  await page.getByRole('button', { name: '保存新建电池组', exact: true }).click()
  await expect(page).toHaveURL(/\/battery-group$/)
  const { data: options } = await api<{ data: TestConfigurationOptions }>(page, '/system/test/options')
  const battery = options.batteries.find(item => item.batteryNo === batteryNo)!
  const channel = options.channels.find(item => item.devid === 1 && item.chlid === 1)!
  const collector = options.collectors.find(item => item.collectorCode === 'dev-sim-collector-1')!
  const { data: detail } = await api<{ data: TestConfigurationDetail }>(page, '/system/test', 'POST', {
    testName: 'Ticket05模拟器与历史导入', principalId: 1,
    testTypeId: options.testTypes.find(item => item.testTypeName === 'Ticket05导入验收')!.testTypeId,
    testPurpose: '验证实时观测与历史分析隔离', monitorTimeoutMinutes: 60, remark: null, planSteps: [],
    batteries: [{ groupId: battery.groupId, batteryNo, collectorId: collector.collectorId,
      binding: { channelId: channel.channelId, expectedBarcode: null }, fields: [],
      collectorChannels: [
        { measurementCode: 'PRIMARY_TEMPERATURE', measurementName: '主温度', channelCode: 'S-01' },
        { measurementCode: 'PRIMARY_POSITIVE_POTENTIAL', measurementName: '正电位', channelCode: 'S-02' },
        { measurementCode: 'PRIMARY_NEGATIVE_POTENTIAL', measurementName: '负电位', channelCode: 'S-03' },
      ] }],
    thresholds: [{ metricCode: 'TEMPERATURE', minValue: 0, maxValue: 25.05, unit: '℃', severity: 'WARNING' }],
  })
  return detail.testId
}
async function confirmReception(page: Page, name: string) {
  await page.getByRole('button', { name, exact: true }).click()
  await page.locator('.el-message-box').getByRole('button', { name, exact: true }).click()
}
async function assertDataPages(page: Page, id: number, counts: number[]) {
  for (const [index, type] of ['REALTIME', 'STEP', 'CYCLE'].entries()) {
    await page.goto(`/test-list/${id}/console?tab=data-list&dataType=${type}`)
    await expect(page.locator('.console-data-panel tbody')).toContainText(batteryNo)
    await expect(page.locator('.console-data-panel')).toContainText(String(counts[index]))
    await page.reload()
    await expect(page.locator('.console-data-panel tbody')).toContainText(batteryNo)
  }
}
test('模拟器观测、每分钟持久化、甲方工作簿导入与重启恢复相互隔离', async ({ page }, testInfo) => {
  const pageErrors: string[] = []
  page.on('pageerror', error => pageErrors.push(error.message))
  await simulator(page, '/devices/1/channels/1/configuration', configuration)
  await simulator(page, '/devices/1/channels/1', { online: true, workStatus: 'WORKING' })
  await simulator(page, '/collectors/1/channels/S-01', {
    metrics: [{ metricCode: 'temperature', value: 25, unit: '℃' }],
  })
  await login(page)
  const id = await createTest(page)
  await page.goto(`/test-list/${id}/console`)
  await test.step('开始接收后轮询当前值,每分钟保存完整快照', async () => {
    await page.getByRole('button', { name: '开始接收', exact: true }).click()
    await expect(page.locator('.reception-state')).toHaveText('接收中')
    await expect(page.locator('.cell-card .temperature')).toContainText('25')
    await expect.poll(async () => (await rows(page, id)).length).toBe(1)
    const first = (await rows(page, id))[0]!
    expect(first).toMatchObject({ voltage: 3.7, temperature: 25 })
    expect(first.positivePotential).not.toBeNull()
    expect(first.negativePotential).not.toBeNull()
    expect(await warnings(page, id)).toEqual([])
    // 使用真实墙上时间跨过完整持久化周期,不调整模拟器时钟或后台调度。
    await expect.poll(async () => (await rows(page, id)).length, { timeout: 75_000, intervals: [2_000] }).toBe(2)
    const times = (await rows(page, id)).map(row => row.serverReceivedAt).sort((a, b) => a - b)
    expect(times[1]! - times[0]!).toBeGreaterThanOrEqual(60_000)
    expect(times[1]! - times[0]!).toBeLessThan(75_000)
    expect(await rows(page, id, 'steps')).toEqual([])
    expect(await rows(page, id, 'cycles')).toEqual([])
  })
  await test.step('5 秒超限先生成预警,不等待下一条持久化记录', async () => {
    const count = (await rows(page, id)).length
    await simulator(page, '/collectors/1/channels/S-01', {
      metrics: [{ metricCode: 'temperature', value: 48.5, unit: '℃' }],
    })
    await expect.poll(async () => (await warnings(page, id)).length, { timeout: 15_000 }).toBe(1)
    await expect(page.locator('.cell-card .temperature')).toContainText('48.5')
    expect((await rows(page, id)).length).toBe(count)
    await simulator(page, '/collectors/1/channels/S-01', {
      metrics: [{ metricCode: 'temperature', value: 25, unit: '℃' }],
    })
    await expect(page.locator('.cell-card .temperature')).toContainText('25')
    await page.screenshot({ path: testInfo.outputPath('live-monitor.png'), fullPage: true })
  })
  await test.step('设备离线持续超过一分钟,不伪造新采样;恢复后只观察当前值', async () => {
    const state = await simulator(page)
    const cabinet = state.devices[0]!
    // 只修改模拟设备事实,测试继续接收,离线期间没有可保存的成功测量。
    await simulator(page, '/devices/1', { ip: '127.0.0.2', channelCount: cabinet.channels.length, online: false })
    await simulator(page, '/collectors/1', { ip: '127.0.1.2', channelCount: state.collectors[0]!.channels.length, online: false })
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
    await expect(page.locator('.channel-panel .device-state')).toHaveText(['离线', '离线'])
    const offlineRows = await rows(page, id)
    const until = Date.now() + 65_000
    while (Date.now() < until) {
      await page.waitForTimeout(5_000)
      expect(await rows(page, id)).toEqual(offlineRows)
    }
    await page.screenshot({ path: testInfo.outputPath('offline.png'), fullPage: true })
    await simulator(page, '/devices/1', { ip: '127.0.0.2', channelCount: cabinet.channels.length, online: true })
    await simulator(page, '/collectors/1', { ip: '127.0.1.2', channelCount: state.collectors[0]!.channels.length, online: true })
    await expect(page.locator('.cell-card .temperature')).toContainText('25')
    await expect.poll(async () => (await rows(page, id)).length).toBe(offlineRows.length + 1)
    expect((await simulator(page)).devices[0]!.channels[0]!.configuration).toEqual(configuration)
  })
  await test.step('暂停、继续和停止不改写设备状态或补采历史', async () => {
    await confirmReception(page, '暂停接收')
    await expect(page.locator('.reception-state')).toHaveText('暂停接收')
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
    const pausedRows = await rows(page, id)
    await page.waitForTimeout(6_000)
    expect(await rows(page, id)).toEqual(pausedRows)
    expect((await simulator(page)).devices[0]!.channels[0]!.workStatus).toBe('WORKING')
    await confirmReception(page, '继续接收')
    await expect(page.locator('.cell-card .temperature')).toContainText('25')
    await confirmReception(page, '停止测试')
    await expect(page.locator('.running-state')).toHaveText('检测完成')
    await expect(page.locator('.reception-state')).toHaveText('已结束接收')
    expect((await simulator(page)).devices[0]!.channels[0]!.workStatus).toBe('WORKING')
  })
  const beforeRows = await rows(page, id)
  const beforeWarnings = await warnings(page, id)
  const imported: DataRow[][] = []
  await test.step('通过真实页面一次导入甲方三表,超限历史不新增或累加预警', async () => {
    await page.getByRole('button', { name: '数据列表', exact: true }).click()
    await page.getByRole('button', { name: '三 Sheet 一键导入', exact: true }).click()
    const liveRequests: string[] = []
    const trackLiveRequest = (request: { url(): string }) => {
      if (/\/live(?:-state)?(?:\?|$)/.test(request.url())) liveRequests.push(request.url())
    }
    page.on('request', trackLiveRequest)
    await page.locator('input[type=file]').setInputFiles(partyExcel)
    for (const count of [7605, 129, 33]) await expect(page.locator('.sheet-summary')).toContainText(String(count))
    await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled()
    await page.screenshot({ path: testInfo.outputPath('workbook-preview.png'), fullPage: true })
    await page.getByRole('button', { name: '确认导入', exact: true }).click()
    await page.locator('.el-message-box input').fill('Ticket05甲方工作簿整体验收')
    await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click()
    await expect(page.locator('.result-stage')).toContainText('导入完成', { timeout: 90_000 })
    await expect(page.locator('.result-stage')).toContainText('7767')
    page.off('request', trackLiveRequest)
    expect(liveRequests).toEqual([])
    for (const kind of ['records', 'steps', 'cycles']) imported.push(await rows(page, id, kind))
    expect(imported.map(items => items.length)).toEqual([beforeRows.length + 7605, 129, 33])
    expect(imported[0]!.filter(row => row.deviceTime < Date.UTC(2024, 0, 1))).toHaveLength(7605)
    expect(imported[0]!.find(row => row.recordNo === '1')).toMatchObject({
      deviceTime: Date.parse('2023-05-08T11:42:11+08:00'), voltage: 1.3349,
      temperature: 25.1, positivePotential: 1.501, negativePotential: -0.101,
    })
    expect(imported[0]!.some(row => row.deviceTime < Date.UTC(2024, 0, 1) && Number(row.temperature) > 25.05)).toBe(true)
    await page.waitForTimeout(6_000)
    expect(await warnings(page, id)).toEqual(beforeWarnings)
    const revisions = await api<{ rows: DataRevisionAction[] }>(page, `/system/data-revision/list?testId=${id}`)
    expect(revisions.rows).toHaveLength(1)
    expect(revisions.rows[0]).toMatchObject({ dataType: 'ALL', operationType: 'IMPORT', reason: 'Ticket05甲方工作簿整体验收' })
    await assertDataPages(page, id, imported.map(items => items.length))
    await page.goto(`/test-list/${id}/console`)
    await expect(page.locator('.cell-card .temperature')).toHaveText('-')
    await expect(page.locator('.alarm-empty')).toContainText('暂无异常')
  })
  await test.step('退出重登和后端重启后,三表完整内容与模拟器配置恢复', async () => {
    await page.getByRole('button', { name: '退出当前账号' }).click()
    await expect(page).toHaveURL(/\/login$/)
    await login(page)
    await assertDataPages(page, id, imported.map(items => items.length))
    await stack.restartBackend()
    await page.reload()
    for (const [index, kind] of ['records', 'steps', 'cycles'].entries()) {
      expect(await rows(page, id, kind)).toEqual(imported[index])
    }
    expect(await warnings(page, id)).toEqual(beforeWarnings)
    expect((await simulator(page)).devices[0]!.channels[0]!.configuration).toEqual(configuration)
    await assertDataPages(page, id, imported.map(items => items.length))
    await page.screenshot({ path: testInfo.outputPath('restored-cycle.png'), fullPage: true })
    await page.goto(`/test-list/${id}/console?tab=project-chart`)
    await expect(page.locator('.project-chart canvas')).toHaveCount(3)
    await page.screenshot({ path: testInfo.outputPath('imported-project-chart.png'), fullPage: true })
  })
  await test.step('相同文件重试全部跳过,且只使用只读观测命令', async () => {
    await page.goto(`/test-list/${id}/console/data/import?mode=workbook`)
    await page.locator('input[type=file]').setInputFiles(partyExcel)
    await page.getByRole('button', { name: '确认导入', exact: true }).click()
    await page.locator('.el-message-box input').fill('Ticket05重复导入核对')
    await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click()
    await expect(page.locator('.result-stage')).toContainText('重复跳过7767 条', { timeout: 90_000 })
    expect(await warnings(page, id)).toEqual(beforeWarnings)
    expect((await api<{ rows: DataRevisionAction[] }>(page, `/system/data-revision/list?testId=${id}`)).rows).toHaveLength(1)
    for (const [index, kind] of ['records', 'steps', 'cycles'].entries()) {
      expect(await rows(page, id, kind)).toEqual(imported[index])
    }
    const state = await simulator(page)
    expect(state.recentRequests.length).toBeGreaterThan(0)
    expect([...observedCommands]).toEqual(expect.arrayContaining(['cabinet-realtime', 'collector-realtime']))
    await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`)
    await expect(page.getByRole('button', { name: /倍速|下载历史|下一循环/ })).toHaveCount(0)
    await page.screenshot({ path: testInfo.outputPath('simulator-final.png'), fullPage: true })
    expect(pageErrors).toEqual([])
    await testInfo.attach('验收事实', { contentType: 'application/json', body: JSON.stringify({
      testId: id, liveRecordCount: beforeRows.length, importedCounts: [7605, 129, 33],
      restoredCounts: imported.map(items => items.length),
      warnings: beforeWarnings.map(item => ({ id: item.exceptionId, type: item.exceptionType, count: item.occurrenceCount })),
      commands: [...observedCommands],
    }, null, 2) })
  })
})