| New file |
| | |
| | | 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) }) |
| | | }) |
| | | }) |