yeg
2026-08-29 02032429319ab2942793b93cda4e65606c42d7b9
补充采集器历史存储恢复验收

扩展隔离全栈测试,验证 Redis 主状态与原始历史拆分、后端重启恢复、旧格式迁移后继续采样,并扫描序列化大小异常日志。
2个文件已修改
86 ■■■■■ 已修改文件
tests/e2e/collector-sampling.spec.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/support/ticket04-stack.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tests/e2e/collector-sampling.spec.ts
@@ -3,6 +3,19 @@
const stack = new Ticket04Stack()
interface StoredCollectorState {
  deviceCode: string
  nextSequence: number
  lastSampledAt: string
  historyRecordCount?: number
  latestMeasurements?: Record<string, unknown>[]
  history?: unknown[]
}
interface StoredSimulatorState {
  collectors: StoredCollectorState[]
}
test.beforeAll(async () => stack.start())
test.afterAll(async () => stack.stop())
@@ -17,13 +30,19 @@
test('采集器登记后可观察独立实时与分页历史采样', async ({ page }) => {
  await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`)
  page.once('dialog', dialog => dialog.accept())
  const simulatorReset = page.waitForResponse(response => response.request().method() === 'POST'
    && response.url().endsWith('/dev/bts-simulator/reset') && response.ok())
  await page.getByRole('button', { name: '重置模拟器' }).click()
  await simulatorReset
  let collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) })
  await expect(collector.getByText('S-01', { exact: true })).toBeVisible()
  await expect(collector.getByText('S-04', { exact: true })).toBeVisible()
  await collector.locator('label').filter({ hasText: '采样间隔(毫秒)' }).locator('input').fill('200')
  const samplingSaved = page.waitForResponse(response => response.request().method() === 'PUT'
    && response.url().endsWith('/dev/bts-simulator/collectors/1/sampling') && response.ok())
  await collector.getByRole('button', { name: '保存配置' }).click()
  await samplingSaved
  collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) })
  await collector.getByLabel('S-01 测量项').fill([
@@ -32,7 +51,10 @@
    'negative_potential=-0.116 V',
    'dynamic_pressure=101.3 kPa',
  ].join('\n'))
  const measurementsSaved = page.waitForResponse(response => response.request().method() === 'PUT'
    && response.url().endsWith('/dev/bts-simulator/collectors/1/channels/S-01') && response.ok())
  await collector.getByRole('button', { name: '保存测量值' }).first().click()
  await measurementsSaved
  collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) })
  await expect(collector.getByLabel('S-01 测量项')).toHaveValue(/dynamic_pressure=101\.3 kPa/)
@@ -44,6 +66,12 @@
  await expect(collector.locator('.collector-output')).toContainText('/ 接收 2026-')
  await collector.getByRole('button', { name: '下载历史' }).click()
  await expect(collector.locator('.collector-output')).toContainText(/历史 \d+ 条 \/ 本页 \d+ 条/)
  const mainState = stack.redisCommand('GET', 'dev:bts-simulator:state:v1')
  expect(mainState).not.toMatch(/\\?"history\\?"\s*:/)
  expect(Number(stack.redisCommand(
    'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1',
  ))).toBeGreaterThan(0)
  await login(page)
  await page.goto('/device-collector/new')
@@ -62,6 +90,30 @@
  await expect(collector.getByLabel('S-01 测量项')).toHaveValue(/dynamic_pressure=101\.3 kPa/)
  await collector.getByRole('button', { name: '下载历史' }).click()
  await expect(collector.locator('.collector-output')).toContainText('dev-sim-collector-1')
  expect(Number(stack.redisCommand(
    'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1',
  ))).toBeGreaterThan(0)
  const legacyState = stack.simulatorState<StoredSimulatorState>()
  const legacyCollector = legacyState.collectors[0]
  const legacyRecord = legacyCollector.latestMeasurements?.[0]
  expect(legacyRecord).toBeDefined()
  delete legacyCollector.historyRecordCount
  delete legacyCollector.latestMeasurements
  legacyCollector.history = [legacyRecord]
  await stack.replaceSimulatorStateAndRestart(legacyState)
  const migratedState = stack.redisCommand('GET', 'dev:bts-simulator:state:v1')
  expect(migratedState).not.toMatch(/\\?"history\\?"\s*:/)
  expect(Number(stack.redisCommand(
    'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1',
  ))).toBeGreaterThan(1)
  await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`)
  collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) })
  await collector.getByRole('button', { name: '下载历史' }).click()
  await expect(collector.locator('.collector-output')).toContainText('dev-sim-collector-1')
  expect(stack.backendLog()).not.toContain('JSONLargeObjectException')
  const commands = await page.locator('#requests .request-command').allTextContents()
  expect(commands.every(command => command === 'getdevinfo' || command === 'inquire')).toBe(true)
tests/e2e/support/ticket04-stack.ts
@@ -1,4 +1,4 @@
import { createWriteStream, existsSync, mkdirSync } from 'node:fs'
import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { resolve } from 'node:path'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
@@ -91,6 +91,7 @@
    await Promise.all([mysqlPort, redisPort, apiPort, simulatorPort].map(assertPortAvailable))
    this.docker = resolveDocker()
    mkdirSync(artifactDir, { recursive: true })
    writeFileSync(resolve(artifactDir, 'backend.log'), '')
    try {
      this.dockerRun([
        'run', '--detach', '--rm', '--name', this.mysqlContainer,
@@ -119,6 +120,37 @@
    await this.startBackend()
  }
  redisCommand(...args: string[]) {
    const result = this.dockerRun([
      'exec', this.redisContainer, 'redis-cli', '-a', redisPassword, '--no-auth-warning', '--raw', ...args,
    ])
    return result.stdout.trim()
  }
  simulatorState<T>() {
    const stored = JSON.parse(this.redisCommand('GET', 'dev:bts-simulator:state:v1'))
    return (typeof stored === 'string' ? JSON.parse(stored) : stored) as T
  }
  backendLog() {
    return readFileSync(resolve(artifactDir, 'backend.log'), 'utf8')
  }
  async replaceSimulatorStateAndRestart(state: unknown) {
    await this.stopBackend()
    const registryKey = 'dev:bts-simulator:collector-history-keys:v1'
    const registeredHistory = this.redisCommand('SMEMBERS', registryKey)
      .split(/\r?\n/).filter(Boolean)
      .map(value => {
        const decoded = JSON.parse(value)
        return typeof decoded === 'string' ? decoded : value
      })
    if (registeredHistory.length > 0) this.redisCommand('DEL', ...registeredHistory)
    this.redisCommand('DEL', registryKey)
    this.redisCommand('SET', 'dev:bts-simulator:state:v1', JSON.stringify(JSON.stringify(state)))
    await this.startBackend()
  }
  async stop() {
    await this.stopBackend()
    if (!this.docker) return