| | |
| | | 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' }, |
| | | ] |
| | | |
| | | interface StoredCollectorState { |
| | | deviceCode: string |
| | | nextSequence: number |
| | | lastSampledAt: string |
| | | historyRecordCount?: number |
| | | latestMeasurements?: Record<string, unknown>[] |
| | | history?: unknown[] |
| | | } |
| | | |
| | | interface StoredSimulatorState { |
| | | collectors: StoredCollectorState[] |
| | | } |
| | | |
| | | test.use({ actionTimeout: 25_000 }) |
| | | test.beforeAll(async () => stack.start()) |
| | | test.afterAll(async () => stack.stop()) |
| | | |
| | |
| | | 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`) |
| | | 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' |
| | | 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 simulatorReset |
| | | |
| | | let collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) }) |
| | | 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() |
| | | 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([ |
| | | 'temperature=28.6 ℃', |
| | | 'positive_potential=3.812 V', |
| | | 'negative_potential=-0.116 V', |
| | | 'dynamic_pressure=101.3 kPa', |
| | | ].join('\n')) |
| | | const measurementsSaved = page.waitForResponse(response => response.request().method() === 'PUT' |
| | | 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 collector.getByRole('button', { name: '保存测量值' }).first().click() |
| | | await measurementsSaved |
| | | 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([]) |
| | | |
| | | 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/) |
| | | await expect(collector.getByText(/原始历史 [1-9]\d* 条/)).toBeVisible() |
| | | await collector.getByRole('button', { name: '查看实时' }).click() |
| | | await expect(collector.locator('.collector-output')).toContainText('dev-sim-collector-1') |
| | | 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-') |
| | | 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*:/) |
| | | const historyBeforePowerOff = Number(stack.redisCommand( |
| | | 'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1', |
| | | )) |
| | | expect(historyBeforePowerOff).toBeGreaterThan(0) |
| | | 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 poweredOff = page.waitForResponse(response => response.request().method() === 'POST' |
| | | && response.url().endsWith('/dev/bts-simulator/power') && response.ok()) |
| | | 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 poweredOff |
| | | await off |
| | | await expect(page.locator('#device-power')).toHaveText('已关机') |
| | | const historyAfterPowerOff = Number(stack.redisCommand( |
| | | 'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1', |
| | | )) |
| | | await page.waitForTimeout(700) |
| | | expect(Number(stack.redisCommand( |
| | | 'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1', |
| | | ))).toBe(historyAfterPowerOff) |
| | | |
| | | expect(await realtime(page)).toMatchObject({ online: false, measurements: [] }) |
| | | await stack.restartBackend() |
| | | await page.goto(`${stack.backendUrl}/dev/bts-simulator.html`) |
| | | await page.goto(stack.backendUrl + '/dev/bts-simulator.html') |
| | | await expect(page.locator('#device-power')).toHaveText('已关机') |
| | | await page.waitForTimeout(700) |
| | | expect(Number(stack.redisCommand( |
| | | 'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1', |
| | | ))).toBe(historyAfterPowerOff) |
| | | |
| | | const poweredOn = page.waitForResponse(response => response.request().method() === 'POST' |
| | | && response.url().endsWith('/dev/bts-simulator/power') && response.ok()) |
| | | 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 poweredOn |
| | | await on |
| | | await expect(page.locator('#device-power')).toHaveText('已开机') |
| | | await expect(page.locator('#error')).not.toHaveClass(/visible/) |
| | | await expect.poll(() => Number(stack.redisCommand( |
| | | 'LLEN', 'dev:bts-simulator:collector-history:v1:dev-sim-collector-1', |
| | | ))).toBeGreaterThan(historyAfterPowerOff) |
| | | await expect.poll(() => { |
| | | const stored = JSON.parse(stack.redisCommand('GET', 'dev:device-discovery:candidates:v1')) |
| | | const candidates = typeof stored === 'string' ? JSON.parse(stored) : stored |
| | | return candidates.collectors.find((item: { deviceCode: string }) => |
| | | item.deviceCode === 'dev-sim-collector-1')?.online |
| | | }, { timeout: 10_000 }).toBe(true) |
| | | 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') |
| | |
| | | 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('Ticket06 验收采集器') |
| | | 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`) |
| | | collector = page.locator('article.device').filter({ has: page.getByRole('heading', { name: 'dev-sim 采集器 1' }) }) |
| | | await expect(collector.locator('label').filter({ hasText: '采样间隔(毫秒)' }).locator('input')).toHaveValue('200') |
| | | 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') |
| | | |
| | | 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.every(command => command === 'getdevinfo' || command === 'inquire')).toBe(true) |
| | | expect(commands.length).toBeGreaterThan(0) |
| | | expect(commands.every(command => readonlyCommands.includes(command))).toBe(true) |
| | | }) |