| | |
| | | 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()) |
| | | await page.getByRole('button', { name: '一键模拟关机' }).click() |
| | | await poweredOff |
| | | 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) |
| | | |
| | | const off = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/dev/bts-simulator/power') && response.ok()) |
| | | await page.getByRole('button', { name: '全部设备离线', exact: true }).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('已关机') |
| | | 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()) |
| | | await page.getByRole('button', { name: '一键模拟开机' }).click() |
| | | await poweredOn |
| | | 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) |
| | | 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: '全部设备上线', exact: true }).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') |
| | |
| | | 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) |
| | | }) |
| | |
| | | expect(saved.items[1].records).toHaveLength(3) |
| | | expect(saved.items[0].records.find((row: any) => row.recordNo === '1').current).toBe(-8.1651) |
| | | expect(saved.items[0].businessFields[0].fieldValue).toBe('12.5') |
| | | expect(saved.settings).toEqual(payload.settings) |
| | | expect(saved.settings).toEqual({ ...payload.settings, sourceDetailOverrides: {} }) |
| | | expect((await api(page, `/system/compare-report/${saved.id}`)).data).toEqual(saved) |
| | | expect(stack.queryDatabase('SELECT COUNT(*) FROM t_compare_report_version')).toBe('1') |
| | | |
| | |
| | | expect(browserSaved.items).toHaveLength(3) |
| | | expect(browserSaved.settings.metrics).not.toContain('positiveVoltage') |
| | | expect(browserSaved.description).toBe('浏览器说明') |
| | | expect(browserSaved.settings.detailFieldValues['质量']['8101|8101|1|1|']).toBe('88') |
| | | expect(browserSaved.settings.detailFieldValues['质量']).toEqual({ '8101|8101': '88' }) |
| | | |
| | | await api(page, '/system/test/8101/console-data/actions', 'POST', { |
| | | dataType: 'REALTIME', operationType: 'EDIT', scopeSummary: '首行电流修订', reason: '验证报表独立', |
| | |
| | | await page.goto('/test-list/42/console') |
| | | await expect(page.getByRole('button', { name: '暂停接收', exact: true })).toBeVisible() |
| | | await page.getByRole('button', { name: '暂停接收', exact: true }).click() |
| | | await expect(page.getByText('暂停接收将停止系统向检测柜和采集器请求新数据,但设备仍继续运行,不会向设备发送暂停命令,是否继续?', { exact: true })).toBeVisible() |
| | | await page.getByRole('button', { name: '暂停接收', exact: true }).last().click() |
| | | await expect(page.locator('.reception-state')).toHaveText('暂停接收') |
| | | await expect(page.getByText('暂停期间停止请求,当前快照不纳入结果', { exact: true }).first()).toBeVisible() |
| | | await expect(page.getByText('已暂停接收,系统已停止请求新数据', { exact: true })).toBeVisible() |
| | | await page.getByRole('button', { name: '继续接收', exact: true }).click() |
| | | await page.getByRole('button', { name: '继续接收', exact: true }).last().click() |
| | |
| | | |
| | | test('测试列表仍可进入控制台数据列表', async ({ page }) => { |
| | | await page.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'ticket20-test-token')) |
| | | await page.route('**/system/testType/list**', route => route.fulfill({ |
| | | status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, total: 0, rows: [] }), |
| | | })) |
| | | const loadedData = new Set<string>() |
| | | for (const kind of ['records', 'steps', 'cycles']) { |
| | | await page.route(`**/system/test/77/data/${kind}`, route => { |
| | | loadedData.add(kind) |
| | | return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, data: [] }) }) |
| | | }) |
| | | } |
| | | await page.route('**/system/test/list**', route => route.fulfill({ |
| | | status: 200, |
| | | contentType: 'application/json', |
| | |
| | | await expect(page).toHaveURL(/\/test-list\/77\/console$/) |
| | | |
| | | await page.getByRole('button', { name: '数据列表', exact: true }).click() |
| | | await expect(page.getByText('查询表格', { exact: true })).toBeVisible() |
| | | const panel = page.locator('.console-data-panel') |
| | | await expect(panel).toBeVisible() |
| | | await expect(panel.locator('.section-heading small')).toHaveText('共 0 条') |
| | | await expect.poll(() => [...loadedData].sort()).toEqual(['cycles', 'records', 'steps']) |
| | | await expect(panel.locator('.load-error')).toHaveCount(0) |
| | | for (const tab of ['实时', '工步', '循环']) { |
| | | const button = panel.getByRole('button', { name: tab, exact: true }) |
| | | await button.click() |
| | | await expect(button).toHaveAttribute('aria-current', 'page') |
| | | await expect(panel.locator('tbody .empty-cell')).toBeVisible() |
| | | } |
| | | await expect(page.getByRole('button', { name: '结果查询', exact: true })).toHaveCount(0) |
| | | }) |
| | |
| | | expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | expect(stack.queryDatabase('SELECT COUNT(*) FROM t_device_adapter')).toBe('0') |
| | | const migrations = stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1') |
| | | expect(migrations).toBe('1,2,3,4,5,6') |
| | | expect(migrations).toBe('1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19') |
| | | await stack.restartBackend('druid,bts') |
| | | expect((await request.get(`${stack.backendUrl}/captchaImage`)).ok()).toBe(true) |
| | | expect(stack.queryDatabase('SELECT GROUP_CONCAT(version ORDER BY installed_rank) FROM flyway_schema_history WHERE success=1')).toBe(migrations) |
| | |
| | | await reset |
| | | |
| | | await expect(page.getByRole('heading', { name: '检测柜 1' })).toBeVisible() |
| | | const paused = await page.request.post(`${stack.backendUrl}/dev/bts-simulator/devices/1/channels/1/playback/pause`) |
| | | expect(paused.ok()).toBe(true) |
| | | // 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.getByLabel('S-01 测量项').fill([ |
| | | 'temperature=48.5 ℃', |
| | | 'positive_potential=3.812 V', |
| | | 'negative_potential=-0.116 V', |
| | | ].join('\n')) |
| | | 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 collector.getByRole('button', { name: '保存测量值' }).first().click() |
| | | 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 expect(page.getByText('已开始接收,未向设备发送控制命令', { exact: true })).toBeVisible() |
| | | await expect(page.locator('.running-state')).toHaveText('检测中') |
| | | await expect(page.locator('.reception-state')).toHaveText('接收中') |
| | | const playback = await page.request.put(`${stack.backendUrl}/dev/bts-simulator/devices/1/channels/1/playback`, { |
| | | data: { speedMultiplier: 60, playing: true }, |
| | | }) |
| | | expect(playback.ok()).toBe(true) |
| | | |
| | | 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('采集器 · 温度超限') |
| | | 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 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).toBeGreaterThan(pausedCount) |
| | | // 继续接收仍按正式分钟采样,不要求在页面轮询的二十秒内入库。 |
| | | 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('三类当前有效数据可修订并形成统一修订记录', async () => { |
| | | await page.request.post(`${stack.backendUrl}/dev/bts-simulator/devices/1/channels/1/playback/next-cycle`) |
| | | await expect.poll(async () => (await persistedRows(page, testId, 'steps')).length, { timeout: 90_000 }).toBeGreaterThan(0) |
| | | await expect.poll(async () => (await persistedRows(page, testId, 'cycles')).length, { timeout: 90_000 }).toBeGreaterThan(0) |
| | | |
| | | 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, '循环', '放电容量', '4.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(4) |
| | | 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 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 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('重新登录、后端重启和 SSE 重连后恢复状态与事实', async () => { |
| | | await page.getByRole('button', { name: '退出当前账号' }).click() |
| | | await page.getByRole('button', { name: '退出', exact: true }).click() |
| | | await login(page) |
| | |
| | | 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('停止立即进入完成态,异常事实仍可人工关闭追溯', async () => { |
| | |
| | | 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, |
| | |
| | | }) |
| | | 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_identity='IMPORT'`)).toBe('1') |
| | | 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_identity='IMPORT'`)).toBe('1') |
| | | 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 |
| | |
| | | |
| | | 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 => command === 'getdevinfo' || command === 'inquire')).toBe(true) |
| | | expect(commands.every(command => ['discover', 'observe', 'cabinet-realtime', 'collector-realtime', 'getdevinfo', 'inquire'].includes(command))).toBe(true) |
| | | }) |
| | | |
| | | await test.step('同通道跨测试复用,所有状态整体删除无痕且不影响其他事实', async () => { |
| | |
| | | }, { requestPath: path, requestInit: init }) as Promise<T> |
| | | } |
| | | |
| | | test('测试创建保存必填双设备绑定、可选映射和字段快照', async ({ page }) => { |
| | | test('测试创建保存必填双设备绑定、可选映射并读取电池组当前字段', async ({ page }) => { |
| | | const suffix = Date.now() |
| | | const cabinetName = `Ticket07检测柜${suffix}` |
| | | const collectorName = `Ticket07采集器${suffix}` |
| | |
| | | await expect(page.locator('.battery-table tbody tr').first().locator('.el-select').last()).toContainText('S-05') |
| | | }) |
| | | |
| | | await test.step('后端拒绝重复映射并保留测试共享字段快照', async () => { |
| | | await test.step('后端拒绝重复映射并按测试电池读取共享字段当前值', async () => { |
| | | const detail = await apiJson<{ code: number; data: Record<string, any> }>(page, `/system/test/${testId}`) |
| | | expect(detail.code).toBe(200) |
| | | expect(detail.data.fieldSnapshots).toEqual(expect.arrayContaining([ |
| | | expect.objectContaining({ fieldName, fieldValue: '72' }), |
| | | ])) |
| | | expect(detail.data.batteries.find((battery: Record<string, any>) => battery.batteryNo === batteryNo)).toMatchObject({ |
| | | batteryNo, fields: expect.arrayContaining([expect.objectContaining({ fieldName, fieldValue: '72' })]), |
| | | }) |
| | | |
| | | const duplicatePayload = { |
| | | ...detail.data, |
| | |
| | | await page.locator('tbody tr').filter({ hasText: groupName }).getByRole('button', { name: '编辑' }).click() |
| | | await page.getByPlaceholder(`输入${fieldName}`).fill('99') |
| | | await page.getByRole('button', { name: '保存编辑电池组', exact: true }).click() |
| | | await expect(page).toHaveURL(/\/battery-group$/) |
| | | const afterGroupChange = await apiJson<{ code: number; data: Record<string, any> }>(page, `/system/test/${testId}`) |
| | | expect(afterGroupChange.data.fieldSnapshots).toEqual(expect.arrayContaining([ |
| | | expect.objectContaining({ fieldName, fieldValue: '72' }), |
| | | ])) |
| | | expect(afterGroupChange.code).toBe(200) |
| | | expect(afterGroupChange.data.batteries.find((battery: Record<string, any>) => battery.batteryNo === batteryNo)).toMatchObject({ |
| | | batteryNo, fields: expect.arrayContaining([expect.objectContaining({ fieldName, fieldValue: '99' })]), |
| | | }) |
| | | }) |
| | | |
| | | await test.step('未开始接收的测试可确认删除并在刷新后保持消失', async () => { |