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
import assert from 'node:assert/strict'
import { readFile, mkdir } from 'node:fs/promises'
import { chromium, expect } from '@playwright/test'
import XLSX from 'xlsx'
import { parseConsoleImportWorkbook } from '../src/utils/consoleDataImport.ts'
 
const workbookPath = process.env.PARTY_EXCEL
assert.ok(workbookPath, '请通过 PARTY_EXCEL 指定甲方验收文件')
const sheets = parseConsoleImportWorkbook(XLSX.readFile(workbookPath), ['BAT-1'])
assert.deepEqual(sheets.map(sheet => sheet.rows.length), [7605, 129, 33])
assert.deepEqual(sheets.flatMap(sheet => [...sheet.errors, ...sheet.rows.flatMap(row => row.errors)]), [])
// 仅模拟 API 返回与编辑持久化;输入数值使用甲方文件的真实解析结果。
const rows = Object.fromEntries(sheets.map(sheet => [sheet.type, sheet.rows.slice(0, 2).map((row, index) => ({ ...row.data, sourceType: 'SNAPSHOT', stableIdentity: `${sheet.type}:${index}`, deviceTime: row.data.recordedAt }))]))
const detail = { testId: 1, testName: '甲方单位对照验收', testStatus: 'COMPLETED', receptionStatus: 'ENDED', thresholds: [], logicStepMappings: [], batteries: [{ testBatteryId: 1, batteryNo: 'BAT-1', groupId: 1, groupName: '演示电池组', fields: [], collectorChannels: [], binding: { channelId: 1 } }] }
const origin = 'http://test-data-units.test'
const output = '../.scratch/data-analysis-v1/units-browser'
await mkdir(output, { recursive: true })
const calls = []
const browser = await chromium.launch({ channel: 'chrome', headless: true })
try {
  const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
  const errors = []
  page.on('pageerror', error => errors.push(error.message))
  await page.addInitScript(() => localStorage.setItem('battery_detection_auth_token', 'browser-test'))
  await page.route(`${origin}/**`, async route => {
    const path = new URL(route.request().url()).pathname
    if (path.startsWith('/assets/')) return route.fulfill({ body: await readFile(`dist${path}`), contentType: path.endsWith('.css') ? 'text/css' : 'application/javascript' })
    if (path.endsWith('/console-data/actions')) {
      const action = route.request().postDataJSON()
      calls.push(action)
      for (const override of action.overrides || []) {
        const row = rows[action.dataType].find(row => row.stableIdentity === override.stableIdentity)
        for (const [field, value] of Object.entries(JSON.parse(override.fieldValues))) row[field] = value === '-' ? null : Number(value)
      }
      return route.fulfill({ json: { code: 200, actionId: calls.length } })
    }
    if (/\/data\/(records|steps|cycles)$/.test(path)) return route.fulfill({ json: { code: 200, data: rows[{ records: 'REALTIME', steps: 'STEP', cycles: 'CYCLE' }[path.split('/').at(-1)]] } })
    if (path.endsWith('/system/test/1')) return route.fulfill({ json: { code: 200, data: detail } })
    if (path.endsWith('/live-state')) return route.fulfill({ json: { code: 200, data: { ...detail, sources: [], exceptions: [] } } })
    if (path.endsWith('/system/batteryGroup/1')) return route.fulfill({ json: { code: 200, data: { groupId: 1, groupName: '演示电池组', f: '2', batteries: [] } } })
    if (path.endsWith('/system/data-revision/1')) return route.fulfill({ json: { code: 200, data: { actionId: 1, testId: 1, testName: detail.testName, dataType: 'REALTIME', operationType: 'EDIT', scopeSummary: '修改电流', identitySummary: '["REALTIME:0"]', changesSummary: JSON.stringify([{ identity: 'REALTIME:0', before: { current: -8.1651, capacity: null }, after: { current: '-8', capacity: '0' } }]), reason: '单位验证', operatorName: 'admin', createdAtUtc: 1683521083000 } } })
    if (path.endsWith('/getInfo')) return route.fulfill({ json: { code: 200, user: { userName: 'admin' }, roles: ['admin'] } })
    if (path.includes('/system/')) return route.fulfill({ json: { code: 200, rows: [], data: [] } })
    return route.fulfill({ body: await readFile('dist/index.html'), contentType: 'text/html' })
  })
  for (const type of ['REALTIME', 'STEP', 'CYCLE']) {
    await page.goto(`${origin}/test-list/1/console/data/import?dataType=${type}`)
    await page.locator('input[type=file]').setInputFiles(workbookPath)
    const field = type === 'REALTIME' ? '电流(mA)' : type === 'STEP' ? '容量(mAh)' : '放电容量(mAh)'
    const expected = type === 'REALTIME' ? -8165.1 : 8512.2
    const previewHeaders = await page.locator('.preview-table-wrap th').allTextContents()
    assert.equal(Number(await page.locator('.preview-table-wrap tbody tr').first().locator('td').nth(previewHeaders.indexOf(field)).textContent()), expected)
    await page.goto(`${origin}/test-list/1/console?tab=data-list&dataType=${type}`)
    await expect(page.locator('.table-wrap tbody')).toContainText('BAT-1')
    await expect(page.locator('.table-wrap thead')).toContainText(field)
    const downloadEvent = page.waitForEvent('download')
    await page.getByRole('button', { name: '下载', exact: true }).click()
    const csv = XLSX.read(await readFile(await (await downloadEvent).path()), { type: 'buffer' })
    const exported = XLSX.utils.sheet_to_json(csv.Sheets[csv.SheetNames[0]])
    assert.ok(exported.some(row => Number(row[field]) === expected))
    await page.screenshot({ path: `${output}/${type}-1440.png`, fullPage: true })
    await page.setViewportSize({ width: 390, height: 844 })
    await expect(page.locator('.app-main')).toHaveCSS('margin-left', '0px')
    await page.screenshot({ path: `${output}/${type}-390.png`, fullPage: true })
    assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, JSON.stringify(await page.evaluate(() => [...document.querySelectorAll('body *')].filter(el => el.getBoundingClientRect().width > innerWidth && getComputedStyle(el).position !== 'absolute').slice(0, 12).map(el => [el.className, el.getBoundingClientRect().width]))))
    await page.setViewportSize({ width: 1440, height: 1000 })
    await page.locator('.table-wrap tbody tr').first().getByRole('button', { name: '编辑', exact: true }).click()
    const label = type === 'REALTIME' ? '电流(mA)' : type === 'STEP' ? '容量(mAh)' : '放电容量(mAh)'
    await page.locator('.single-form-grid label').filter({ hasText: label }).locator('input').fill(type === 'REALTIME' ? '-8000' : '9000')
    await page.locator('.remark-field textarea').fill('毫单位编辑验证')
    await page.getByRole('button', { name: '提交修改', exact: true }).click()
    await expect(page.locator('.el-dialog')).toBeHidden()
    const key = type === 'REALTIME' ? 'current' : type === 'STEP' ? 'capacity' : 'dischargeCapacity'
    assert.deepEqual(JSON.parse(calls.at(-1).overrides[0].fieldValues), { [key]: type === 'REALTIME' ? '-8' : '9' })
    assert.equal(JSON.parse(calls.at(-1).changesSummary)[0].after[key], type === 'REALTIME' ? '-8' : '9')
    await page.reload()
    await expect(page.locator('.table-wrap tbody')).toContainText(type === 'REALTIME' ? '-8000.0000' : '9000.0000')
    await page.getByRole('button', { name: '批量修改', exact: true }).click()
    const headers = await page.locator('.table-wrap th').allTextContents()
    const index = headers.indexOf(field)
    for (const [i, row] of (await page.locator('.table-wrap tbody tr').all()).entries()) await row.locator('td').nth(index).locator('input').fill(i ? '-' : '0')
    await page.getByRole('button', { name: '保存修改', exact: true }).click()
    await page.locator('.el-message-box input').fill('批量零值及清空验证')
    await page.locator('.el-message-box').getByRole('button', { name: '确定', exact: true }).click()
    await expect(page.getByRole('button', { name: '批量修改', exact: true })).toBeVisible()
    assert.deepEqual(calls.at(-1).overrides.map(override => JSON.parse(override.fieldValues)), [{ [key]: '0' }, { [key]: '-' }])
  }
  // 图表使用原始导入值,避免被上面的编辑场景改变对照基线。
  for (const sheet of sheets) rows[sheet.type] = sheet.rows.slice(0, 8).map((row, i) => ({ ...row.data, sourceType: 'SNAPSHOT', stableIdentity: `${sheet.type}:${i}`, deviceTime: row.data.recordedAt }))
  for (const tab of ['step-chart', 'project-chart']) {
    await page.goto(`${origin}/test-list/1/console?tab=${tab}`)
    await expect(page.locator('canvas').first()).toBeVisible()
    for (const width of [1440, 390]) {
      await page.setViewportSize({ width, height: width === 1440 ? 1000 : 844 })
      await expect(page.locator('.app-main')).toHaveCSS('margin-left', width === 390 ? '0px' : '248px')
      await expect.poll(() => page.locator('canvas').first().evaluate(canvas => canvas.getBoundingClientRect().width)).toBeGreaterThan(250)
      await page.screenshot({ path: `${output}/${tab}-${width}.png`, fullPage: true, animations: 'disabled' })
      const nonblank = await page.locator('canvas').evaluateAll(canvases => canvases.some(canvas => {
        const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data
        return pixels.some((value, index) => index % 4 === 3 && value !== 0)
      }))
      assert.ok(nonblank, `${tab} 必须实际绘图`)
      assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
    }
  }
  await page.goto(`${origin}/data-revision/1`)
  await expect(page.locator('.change-list')).toContainText('电流(mA)')
  await expect(page.locator('.change-list')).toContainText('-8165.1000')
  await expect(page.locator('.change-list')).toContainText('-8000.0000')
  await expect(page.locator('.change-list')).toContainText('容量(mAh)')
  await expect(page.locator('.change-list')).toContainText('0.0000')
  assert.deepEqual(errors, [])
  console.log('甲方三表预览、导出、单条和批量编辑、修订详情、刷新、桌面/窄屏图表及非空画布通过')
} finally {
  await browser.close()
}