yeg
2026-09-08 4cac6bb94d7318be719bf5edda4e22f19bc7b003
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
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
 
const stack = new Ticket04Stack()
const readonlyCommands = ['discover', 'observe', 'cabinet-realtime', 'collector-realtime', 'getdevinfo', 'inquire']
interface Measurement { channelCode: string; metricCode: string; value: number; unit: string; sequenceId: number; deviceTimeRaw: string }
interface CollectorSnapshot { online: boolean; measurements: Measurement[] }
const configuredMetrics = [
  { metricCode: 'temperature', value: 28.6, unit: '℃' },
  { metricCode: 'positive_potential', value: 3.812, unit: 'V' },
  { metricCode: 'negative_potential', value: -0.116, unit: 'V' },
  { metricCode: 'dynamic_pressure', value: 101.3, unit: 'kPa' },
]
 
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 realtime(page: Page): Promise<CollectorSnapshot> {
  const response = await page.request.get(stack.backendUrl + '/dev/bts-simulator/collectors/1/realtime')
  expect(response.ok()).toBe(true)
  return response.json()
}
async function collectorState(page: Page) {
  const response = await page.request.get(stack.backendUrl + '/dev/bts-simulator/state')
  expect(response.ok()).toBe(true)
  const state = await response.json()
  expect(state.recentRequests.every((item: { command: string }) => readonlyCommands.includes(item.command))).toBe(true)
  return state.collectors.find((item: { deviceCode: string }) => item.deviceCode === 'dev-sim-collector-1')
}
 
test('采集器登记、请求时当前观测、测量配置与重启持久化', async ({ page }) => {
  await page.goto(stack.backendUrl + '/dev/bts-simulator.html')
  page.once('dialog', dialog => dialog.accept())
  const reset = page.waitForResponse(response => response.request().method() === 'POST'
    && response.url().endsWith('/dev/bts-simulator/reset') && response.ok())
  await page.getByRole('button', { name: '重置模拟器' }).click()
  await reset
  const 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()
  const channel = collector.locator('tbody tr').filter({ has: page.getByText('S-01', { exact: true }) })
  await channel.getByRole('button', { name: '编辑测量值', exact: true }).click()
  const editor = page.getByRole('dialog', { name: '采集器 1 · S-01 测量值', exact: true })
  for (let index = 0; index < 3; index++) await editor.getByLabel('数值', { exact: true }).nth(index).fill(String(configuredMetrics[index]!.value))
  await editor.getByRole('button', { name: '添加测量项', exact: true }).click()
  const custom = editor.locator('tbody tr').last()
  await custom.getByLabel('自定义名称', { exact: true }).fill('dynamic_pressure')
  await custom.getByLabel('数值', { exact: true }).fill('101.3')
  await custom.getByLabel('单位', { exact: true }).fill('kPa')
  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(channel).toContainText('dynamic_pressure 101.3 kPa')
  const configured = await collectorState(page)
  expect(configured.channels[0].metrics).toEqual(configuredMetrics)
  expect(configured.latestMeasurements).toEqual([])
 
  const observed = page.waitForResponse(response => response.url().endsWith('/collectors/1/realtime') && response.ok())
  await collector.getByRole('button', { name: '查看实时', exact: true }).click()
  const first: CollectorSnapshot = await (await observed).json()
  expect(first.online).toBe(true)
  expect(first.measurements).toHaveLength(13)
  expect(first.measurements.filter(item => item.channelCode === 'S-01').map(({ metricCode, value, unit }) => ({ metricCode, value, unit }))).toEqual(configuredMetrics)
  await expect(collector.locator('.collector-output')).toContainText('S-01 dynamic_pressure=101.3 kPa')
  await expect(collector.locator('.collector-output')).toContainText('设备 2026-')
  await expect(collector.locator('.collector-output')).toContainText('/ 接收 2026-')
  const second = await realtime(page)
  expect(second.measurements[0]!.sequenceId).toBeGreaterThan(first.measurements[0]!.sequenceId)
  expect(second.measurements.map(item => item.value)).toEqual(first.measurements.map(item => item.value))
  const beforeIdle = await collectorState(page)
  // ADR-0048:状态读取和请求空档不生成观测,也不积累历史。
  await page.waitForTimeout(1_100)
  expect((await collectorState(page)).latestMeasurements).toEqual(beforeIdle.latestMeasurements)
  expect(stack.redisCommand('EXISTS', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1')).toBe('0')
  expect(stack.redisCommand('GET', 'dev:bts-simulator:state:v1')).not.toMatch(/\\?"history\\?"\s*:/)
 
  page.once('dialog', dialog => dialog.accept())
  const off = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/dev/bts-simulator/power') && response.ok())
  await page.getByRole('button', { name: '一键模拟关机' }).click()
  await off
  await expect(page.locator('#device-power')).toHaveText('已关机')
  expect(await realtime(page)).toMatchObject({ online: false, measurements: [] })
  await stack.restartBackend()
  await page.goto(stack.backendUrl + '/dev/bts-simulator.html')
  await expect(page.locator('#device-power')).toHaveText('已关机')
  expect(await realtime(page)).toMatchObject({ online: false, measurements: [] })
  expect((await collectorState(page)).channels[0].metrics).toEqual(configuredMetrics)
  const on = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/dev/bts-simulator/power') && response.ok())
  await page.getByRole('button', { name: '一键模拟开机' }).click()
  await on
  await expect(page.locator('#device-power')).toHaveText('已开机')
  const resumed = await realtime(page)
  expect(resumed.measurements).toHaveLength(13)
  expect(resumed.measurements[0]!.sequenceId).toBeGreaterThan(second.measurements[0]!.sequenceId)
  expect(resumed.measurements.map(item => item.value)).toEqual(first.measurements.map(item => item.value))
 
  await login(page)
  await page.goto('/device-collector/new')
  await page.getByRole('button', { name: '选择设备' }).click()
  const candidate = page.locator('.device-table tbody tr').filter({ hasText: 'dev-sim-collector-1' })
  await expect(candidate).toBeVisible()
  await candidate.getByRole('button', { name: '选择' }).click()
  await page.locator('label').filter({ hasText: '采集器名称' }).locator('input').fill('实时观测验收采集器')
  await page.getByRole('button', { name: '保存', exact: true }).click()
  await expect(page.locator('tbody tr').filter({ hasText: 'dev-sim-collector-1' })).toBeVisible()
  await stack.restartBackend()
  await page.goto(stack.backendUrl + '/dev/bts-simulator.html')
  expect((await collectorState(page)).channels[0].metrics).toEqual(configuredMetrics)
  expect((await realtime(page)).measurements.map(item => item.value)).toEqual(first.measurements.map(item => item.value))
  await collector.getByRole('button', { name: '查看实时', exact: true }).click()
  await expect(collector.locator('.collector-output')).toContainText('S-01 dynamic_pressure=101.3 kPa')
  const commands = await page.locator('#requests .request-command').allTextContents()
  expect(commands.length).toBeGreaterThan(0)
  expect(commands.every(command => readonlyCommands.includes(command))).toBe(true)
})