yeg
2026-09-07 802057a42167d8bd99be4d0b08c25cff422d1535
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
import { expect, test, type Page } from '@playwright/test'
import { Ticket04Stack } from './support/ticket04-stack'
 
const stack = new Ticket04Stack()
const kinds = ['compare', 'formation', 'splice'] as const
type Kind = typeof kinds[number]
const sources = [9301, 9302, 9303, 9304]
const selection = (id: number, battery = id) => ({ testId: id, testBatteryId: battery })
const compareSelections = [9301, 9303].flatMap(id => [1, 3].map(cycleNo => ({ ...selection(id), cycleNo, stepNo: 1, logicalNo: null })))
const colors = ['#123456', '#3978f6', '#f08a3e', '#109854']
 
test.use({ actionTimeout: 20_000 })
test.beforeAll(async () => { await stack.start(); seed() })
test.afterAll(async () => stack.stop())
 
async function login(page: Page, username = 'admin') {
  await page.goto('/login')
  await page.evaluate(() => {
    localStorage.removeItem('battery_detection_auth_token')
    localStorage.removeItem('battery_detection_auth_user')
  })
  await page.goto('/login')
  await page.locator('input[autocomplete="username"]').fill(username)
  await page.locator('input[autocomplete="current-password"]').fill(username === 'admin' ? 'admin123' : 'Report123!')
  await page.getByRole('button', { name: '登录', exact: true }).click()
  await expect(page).toHaveURL(/\/dashboard$/)
}
 
async function request(page: Page, path: string, method = 'GET', data?: unknown) {
  const token = await page.evaluate(() => localStorage.getItem('battery_detection_auth_token'))
  return (await page.request.fetch(`${stack.backendUrl}/system/${path}`, {
    method, data, headers: { Authorization: `Bearer ${token}` },
  })).json()
}
 
async function api(page: Page, path: string, method = 'GET', data?: unknown) {
  const body = await request(page, path, method, data)
  expect(body.code, `${path}: ${body.msg}`).toBe(200)
  return body
}
 
function payload(kind: Kind, name: string) {
  const settings = kind === 'compare'
    ? { metrics: ['voltage', 'current', 'temperature'], seriesColors: colors, batteryTemplateFields: ['F'], specialFields: [], detailFieldValues: {} }
    : kind === 'formation'
      ? { fields: [{ key: 'chargeCapacity', scope: 'cycle', kind: 'metric', displayName: '充电容量', unit: 'mAh' }], values: {}, chargeStepSelections: {}, metrics: ['voltage', 'current', 'temperature'], seriesColors: {} }
      : { batteryFields: [], specialFields: [], detailFieldValues: {}, metrics: ['finalVoltage1', 'energyEfficiency'], seriesColors: colors }
  return { name, description: 'Ticket13 隔离受控来源', ...(kind === 'compare' ? { experimentName: '' } : {}), settings,
    selections: kind === 'compare' ? compareSelections : (kind === 'formation' ? sources : [9301, 9303]).map(id => selection(id)) }
}
 
// 仅向本次临时库导入确定的完成态事实;不连接设备,不以此证明真实协议兼容。
function seed() {
  stack.executeIsolatedSql(`
    INSERT INTO t_battery_group(group_id,group_name,model) VALUES(9301,'连续验收组','验收型号'),(9304,'异组同名','验收型号');
    INSERT INTO t_battery_group_relation_field(group_id,battery_group_field_id,field_value)
      SELECT 9301,field_id,'12.5' FROM t_battery_group_field WHERE field_name='F';
    INSERT INTO t_test_type(test_type_id,test_type_name) VALUES(9301,'化成');
    INSERT INTO t_test(test_id,test_name,creator_id,principal_id,test_type_id,test_purpose,test_status,reception_status,started_at_utc)
      VALUES ${[...sources, 9305].map((id, i) => `(${id},'连续来源${i + 1}',1,1,9301,'隔离受控导入','${id === 9305 ? 'WAITING' : 'COMPLETED'}','${id === 9305 ? 'NOT_STARTED' : 'ENDED'}','2026-09-0${i + 1} 00:00:00')`).join(',')};
    INSERT INTO t_test_battery(test_battery_id,test_id,group_id,battery_no,collector_id)
      VALUES ${[...sources, 9305].map(id => `(${id},${id},${id === 9304 ? 9304 : 9301},'同名电池',${id})`).join(',')},
      (9401,9301,9301,'第二电池',9401),(9402,9302,9301,'第二电池',9402),(9403,9303,9301,'第二电池',9403);
  `)
  for (const battery of [...sources, 9401, 9402, 9403]) {
    const id = battery >= 9401 ? battery - 100 : battery
    const cycles = id === 9302 ? [5, 7] : id === 9303 ? [1, 3, 5] : [1, 3]
    stack.executeIsolatedSql(`
      INSERT INTO t_test_realtime_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,cabinet_devtype,cabinet_devid,cabinet_subdevid,cabinet_chlid,cabinet_sequence_id,cabinet_device_time_utc,collector_device_time_utc,server_received_at_utc,cycle_no,step_no,step_name,voltage_v,current_a,temperature,positive_potential,negative_potential,content_sha256)
        VALUES ${cycles.flatMap(cycle => [0, 1, 2].map(point => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 10 + point}',27,1,1,${battery},${cycle * 10 + point},'2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:10.123','2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:00.123','2026-09-01 0${cycle}:0${point === 2 ? 4 : point}:11.123',${cycle},1,'实际充电',3.7,${point === 0 ? '-8.1651' : point === 1 ? 'NULL' : '0'},0,0.1,-0.2,UNHEX(SHA2('${battery}:${cycle}:${point}',256)))`)).join(',')};
      INSERT INTO t_test_step_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,step_no,step_name,step_type,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,voltage_v,content_sha256)
        VALUES ${cycles.flatMap(cycle => [1, 2].map(step => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle * 10 + step}','${cycle * 10 + step}',${cycle},${step},'${step === 1 ? '实际充电' : '实际放电'}','${step === 1 ? 'cc' : 'dc'}','2026-09-01 0${cycle}:0${step}:00.123','2026-09-01 0${cycle}:0${step}:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',${cycle === 3 && step === 2 ? 'NULL' : step === 1 ? '4.2' : '0'},UNHEX(SHA2('${battery}:${cycle}:${step}',256)))`)).join(',')};
      INSERT INTO t_test_cycle_record(test_id,test_battery_id,binding_id,adapter_code,source_identity,source_record_id,dedupe_key,cycle_no,device_time_utc,server_received_at_utc,adapter_timezone_id,device_time_raw,charge_capacity_ah,discharge_capacity_ah,charge_energy_wh,discharge_energy_wh,content_sha256)
        VALUES ${cycles.map(cycle => `(${id},${battery},${battery},'acceptance','acceptance:${battery}','${cycle}','${cycle}',${cycle},'2026-09-01 0${cycle}:03:00.123','2026-09-01 0${cycle}:03:01.123','Asia/Hong_Kong','2026-09-01 08:00:00.123',8.5122,0,${cycle === 3 ? '0' : '0.1361'},0.1694,UNHEX(SHA2('${battery}:${cycle}',256)))`).join(',')};
    `)
  }
}
 
test('同一环境连续保存三类报表,三子页修订、失败回滚、重启及删源均保持版本独立', async ({ page }) => {
  await login(page)
  const saved: Partial<Record<Kind, any>> = {}
  for (const kind of kinds) {
    const candidates = (await api(page, `${kind}-report/sources`)).data
    expect(candidates.some((row: any) => row.selection.testId === 9305)).toBe(false)
    const draft = payload(kind, `连续验收-${kind}`)
    await api(page, `${kind}-report/preview`, 'POST', draft.selections)
    saved[kind] = (await api(page, `${kind}-report`, 'POST', draft)).data
    expect(saved[kind].versionNo).toBe(1)
  }
  expect(saved.compare.items).toHaveLength(4)
  expect(saved.compare.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('12.5')
  expect(saved.compare.items[0].zeroTime).toBe(Date.parse('2026-09-01T01:00:00.123Z'))
  expect(saved.formation.cycleNos).toEqual([1, 3, 5, 7])
  expect(saved.formation.items[0].cycleNos).toEqual([1, 3])
  expect(saved.splice.segments[0].statistics['1'].energyEfficiency).toBeCloseTo(124.467303453, 6)
 
  for (const [kind, endpoint, field, value] of [
    ['REALTIME', 'records', 'current', -2], ['STEP', 'steps', 'endVoltage', 5.111], ['CYCLE', 'cycles', 'chargeCapacity', 9.7178],
  ] as const) {
    const rows = (await api(page, `test/9301/data/${endpoint}`)).data
    const row = rows.find((item: any) => item.cycleNo === 1 && item.batteryNo === '同名电池' && (kind !== 'STEP' || item.stepNo === 2))
    expect(row).toBeTruthy()
    await api(page, 'test/9301/console-data/actions', 'POST', {
      dataType: kind, operationType: 'EDIT', scopeSummary: `${kind} 连续验收修订`, reason: '核对三类报表版本独立',
      identitySummary: JSON.stringify([row.stableIdentity]),
      overrides: [{ stableIdentity: row.stableIdentity, fieldValues: JSON.stringify({ [field]: value }) }],
    })
    expect((await api(page, `test/9301/data/${endpoint}`)).data.find((item: any) => item.stableIdentity === row.stableIdentity)[field]).toBe(value)
  }
  stack.executeIsolatedSql("UPDATE t_battery_group_relation_field SET field_value='25' WHERE group_id=9301")
  const refreshed: Partial<Record<Kind, any>> = {}
  for (const kind of kinds) {
    const first = saved[kind]
    const path = `${kind}-report/${first.id}`
    expect((await api(page, path)).data).toEqual(first)
    const edit = { ...payload(kind, `连续编辑-${kind}`), selections: undefined, baseVersionNo: 1, changeSummary: '仅调整名称' }
    // 约束作用于真实写库过程,验证版本插入后失败会回滚,不模拟成功响应。
    stack.executeIsolatedSql(`ALTER TABLE t_${kind}_report ADD CONSTRAINT ticket13_save_failure CHECK (report_name <> 'failure')`)
    try { expect((await request(page, `${path}/versions`, 'POST', { ...edit, name: 'failure' })).code).not.toBe(200) }
    finally { stack.executeIsolatedSql(`ALTER TABLE t_${kind}_report DROP CONSTRAINT ticket13_save_failure`) }
    expect((await api(page, `${path}/versions`)).data).toHaveLength(1)
    expect((await api(page, path)).data).toEqual(first)
    const edited = (await api(page, `${path}/versions`, 'POST', edit)).data
    expect(kind === 'splice' ? edited.segments : edited.items).toEqual(kind === 'splice' ? first.segments : first.items)
    refreshed[kind] = (await api(page, `${path}/refresh-source`, 'POST', { baseVersionNo: 2, changeSummary: '主动读取三子页修订' })).data
    expect((await api(page, `${path}/versions/1`)).data).toEqual(first)
    for (const other of kinds.filter(item => item !== kind)) {
      expect((await api(page, `${other}-report/${saved[other].id}/versions/1`)).data).toEqual(saved[other])
    }
  }
  expect(refreshed.compare.items[0].records.some((row: any) => row.current === -2)).toBe(true)
  expect(refreshed.compare.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('25')
  expect((await api(page, `compare-report/${saved.compare.id}/versions/1`)).data.items[0].businessFields.find((field: any) => field.fieldName === 'F').fieldValue).toBe('12.5')
  expect(refreshed.formation.items[0].cycles.find((row: any) => row.cycleNo === 1).chargeCapacity).toBe(9.7178)
  expect(refreshed.splice.segments[0].statistics['1'].finalVoltage1).toBe(5.111)
  await stack.restartBackend()
  await login(page)
  const errors: string[] = []
  page.on('pageerror', error => errors.push(error.message))
  for (const kind of kinds) {
    expect((await api(page, `${kind}-report/${saved[kind].id}`)).data).toEqual(refreshed[kind])
    await page.goto(`/report-${kind}/${saved[kind].id}/edit?mode=view`)
    await page.reload()
    if (kind === 'splice') await expect(page.locator('.splice-snapshot h1')).toHaveText(`连续编辑-${kind}`)
    else await expect(page.getByPlaceholder('请输入报表名称')).toHaveValue(`连续编辑-${kind}`)
    await expect(page.getByRole('button', { name: '历史版本', exact: true })).toBeEnabled()
  }
  const logsBefore = stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log')
  await api(page, 'test/9301', 'DELETE')
  expect(stack.queryDatabase('SELECT COUNT(*) FROM sys_oper_log')).toBe(logsBefore)
  for (const table of ['t_test', 't_test_battery', 't_test_realtime_record', 't_test_step_record', 't_test_cycle_record', 't_test_data_revision_action']) {
    expect(stack.queryDatabase(`SELECT COUNT(*) FROM ${table} WHERE test_id=9301`)).toBe('0')
  }
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  for (const kind of kinds) {
    const path = `${kind}-report/${saved[kind].id}`
    expect((await api(page, `${path}/versions/1/print`)).data).toEqual(saved[kind])
    expect((await request(page, `${path}/refresh-source`, 'POST', { baseVersionNo: 3, changeSummary: '已删除来源' })).code).not.toBe(200)
    const edited = (await api(page, `${path}/versions`, 'POST', {
      ...payload(kind, `删源编辑-${kind}`), selections: undefined, baseVersionNo: 3, changeSummary: '删源后保留快照',
    })).data
    expect(kind === 'splice' ? edited.segments : edited.items).toEqual(kind === 'splice' ? refreshed[kind].segments : refreshed[kind].items)
    await page.goto(`/report-${kind}/${saved[kind].id}/edit?mode=view&version=1`)
    for (const width of [1440, 390]) {
      await page.setViewportSize({ width, height: 900 })
      await expect(page.getByRole('button', { name: '打印 / 另存 PDF', exact: true })).toBeVisible()
      await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
      await page.screenshot({ path: `tests/e2e/.artifacts/ticket13-${kind}-${width}.png`, fullPage: true })
    }
    const popupPromise = page.waitForEvent('popup')
    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
    const popup = await popupPromise
    await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
    await expect(popup.locator('body')).toContainText(`连续验收-${kind}`)
    await popup.pdf({ path: `tests/e2e/.artifacts/ticket13-${kind}.pdf`, preferCSSPageSize: true, printBackground: true })
    await popup.close()
    expect((await api(page, `${path}/versions`)).data).toHaveLength(4)
    await page.evaluate(() => { window.open = () => null })
    await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
    await expect(page.getByText('打印窗口打开失败,请允许浏览器弹出窗口后重试', { exact: true })).toBeVisible()
  }
  const retainedSource = (await api(page, 'test/9303/data/cycles')).data
  for (const kind of kinds) {
    const path = `${kind}-report/${saved[kind].id}`
    await api(page, path, 'DELETE')
    expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_${kind}_report_version WHERE report_id=${saved[kind].id}`)).toBe('0')
    expect((await request(page, `${path}/versions/1`)).code).not.toBe(200)
    const log = JSON.parse(stack.queryDatabase(`SELECT oper_param FROM sys_oper_log WHERE oper_url='/system/${path}' AND business_type=3`))
    expect(Object.keys(log).sort()).toEqual(['operatorId', 'reportId', 'reportName'])
    expect(log.reportId).toBe(saved[kind].id)
    expect((await api(page, 'test/9303/data/cycles')).data).toEqual(retainedSource)
    for (const other of kinds.slice(kinds.indexOf(kind) + 1)) {
      expect((await api(page, `${other}-report/${saved[other].id}/versions/1`)).data).toEqual(saved[other])
    }
  }
  expect(errors).toEqual([])
})
 
test('拼接前置行为:共同多电池、跳号和末工步缺失', async ({ page }) => {
  await login(page)
  // 后段从循环5开始,必须平移到前段末循环5之后;不额外假设首段的归零规则。
  const single = [selection(9303), selection(9302)]
  const preview = (await api(page, 'splice-report/preview', 'POST', single)).data
  const nextSegment = preview.segments[1]
  expect.soft(nextSegment.originalCycleNos.map((no: number) => no + nextSegment.offset), '后段应紧接前段且保留段内跳号').toEqual([6, 8])
  expect.soft(preview.segments[0].statistics['3'].finalVoltage1 ?? null, '最后实际工步缺失,不能回退到 4.2 V').toBeNull()
  const multiple = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9302, 9402), selection(9303), selection(9303, 9403)])
  expect.soft(multiple.code, `共同多电池:${multiple.msg}`).toBe(200)
  const mismatch = await request(page, 'splice-report/preview', 'POST', [selection(9302), selection(9304)])
  expect(mismatch.code).not.toBe(200)
  for (const invalid of [
    [selection(9302), selection(9302, 9402)],
    [selection(9302), selection(9303), selection(9303, 9403)],
    [selection(9302), selection(9305)],
    [selection(9302, 9403), selection(9303)],
  ]) expect((await request(page, 'splice-report/preview', 'POST', invalid)).code).not.toBe(200)
  try {
    for (const value of ['-0.1361', 'NULL']) {
      stack.executeIsolatedSql(`UPDATE t_test_cycle_record SET charge_energy_wh=${value} WHERE test_id=9302 AND cycle_no=5`)
      const result = (await api(page, 'splice-report/preview', 'POST', single)).data
      expect(result.segments[1].statistics['5'].energyEfficiency ?? null).toBeNull()
    }
  } finally { stack.executeIsolatedSql('UPDATE t_test_cycle_record SET charge_energy_wh=0.1361 WHERE test_id=9302 AND cycle_no=5') }
})
 
test('删除日志写入失败必须保留整份拼接报表和所有版本', async ({ page }) => {
  await login(page)
  const single = [selection(9302), selection(9303)]
  const created = (await api(page, 'splice-report', 'POST', { ...payload('splice', '删除失败保留完整报表'), selections: single })).data
  const edited = (await api(page, `splice-report/${created.id}/versions`, 'POST', {
    name: '删除失败保留两个版本', description: '', settings: created.settings, baseVersionNo: 1, changeSummary: '追加历史用于核对完整回滚',
  })).data
  stack.executeIsolatedSql(`ALTER TABLE sys_oper_log ADD CONSTRAINT ticket13_delete_failure CHECK (oper_url <> '/system/splice-report/${created.id}')`)
  try {
    expect((await request(page, `splice-report/${created.id}`, 'DELETE')).code).not.toBe(200)
    const reopened = await request(page, `splice-report/${created.id}`)
    expect(reopened.code, '日志插入失败必须回滚整份删除').toBe(200)
    expect(reopened.data).toEqual(edited)
    expect((await api(page, `splice-report/${created.id}/versions/1`)).data).toEqual(created)
    expect((await api(page, `splice-report/${created.id}/versions`)).data).toHaveLength(2)
    await page.goto('/report-splice')
    const row = page.locator('tbody tr').filter({ hasText: edited.name })
    await row.getByRole('button', { name: '删除', exact: true }).click()
    const rejected = page.waitForResponse(response => response.url().endsWith(`/system/splice-report/${created.id}`) && response.request().method() === 'DELETE')
    await page.getByRole('dialog', { name: '确认删除拼接报表', exact: true }).getByRole('button', { name: '删除', exact: true }).click()
    expect((await (await rejected).json()).code).not.toBe(200)
    await expect(page.locator('.el-message--error')).toBeVisible()
    await expect(page.getByText('拼接报表已删除', { exact: true })).toHaveCount(0)
    await expect(row).toBeVisible()
  } finally { stack.executeIsolatedSql('ALTER TABLE sys_oper_log DROP CONSTRAINT ticket13_delete_failure') }
  await api(page, `splice-report/${created.id}`, 'DELETE')
  expect((await request(page, `splice-report/${created.id}`)).code).not.toBe(200)
  expect(stack.queryDatabase(`SELECT COUNT(*) FROM t_splice_report_version WHERE report_id=${created.id}`)).toBe('0')
  expect(stack.queryDatabase(`SELECT COUNT(*) FROM sys_oper_log WHERE oper_url='/system/splice-report/${created.id}' AND business_type=3`)).toBe('1')
})
 
test('拼接模板仅保存所属类型字段结构,业务字段和人工指标随版本独立保存', async ({ page }) => {
  await login(page)
  const fields = [{ name: 'F', category: 'battery', displayName: '业务F', unit: '' }, { name: '说明项', category: 'special', displayName: '人工说明', unit: '' }]
  const template = (await api(page, 'splice-report/templates', 'POST', { name: '拼接专用字段', fields })).data
  const templates = (await api(page, 'splice-report/templates')).data
  const stored = templates.find((item: any) => item.id === template)
  expect(stored.type).toBe('splice')
  expect(stored.fields[0].fieldDefinitionId).toBeGreaterThan(0)
  for (const kind of ['compare', 'formation']) expect((await api(page, `${kind}-report/templates`)).data.some((item: any) => item.name === stored.name)).toBe(false)
  expect((await request(page, 'splice-report/templates', 'POST', { name: '夹带来源', fields, selections: [selection(9302)] })).code).not.toBe(200)
  expect((await request(page, 'splice-report/templates', 'POST', { name: '夹带值', fields: [{ ...fields[1], value: '不允许' }] })).code).not.toBe(200)
  const selections = [selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)]
  const draft = { ...payload('splice', '模板与人工统计'), selections, settings: { ...payload('splice', '').settings, fieldStructure: stored.fields, statisticValues: {} } }
  const first = (await api(page, 'splice-report', 'POST', draft)).data
  expect(first.segments[0].businessFields.map((field: any) => field.fieldName)).toEqual(['F'])
  expect(first.cycleNos).toEqual([1, 3, 5, 6, 8])
  expect(first.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1])
  const original = (await api(page, 'test/9303/data/steps')).data
  const second = (await api(page, `splice-report/${first.id}/versions`, 'POST', { name: first.name, description: '', baseVersionNo: 1, changeSummary: '人工调整', settings: { ...first.settings, statisticValues: { '9303|9303|3|finalVoltage1': '-1.25' } } })).data
  expect(second.segments).toEqual(first.segments)
  expect(second.settings.statisticValues['9303|9303|3|finalVoltage1']).toBe('-1.25')
  expect((await api(page, 'test/9303/data/steps')).data).toEqual(original)
  await api(page, `splice-report/templates/${template}`, 'PUT', { name: stored.name, fields: [fields[1]] })
  expect((await api(page, `splice-report/${first.id}/versions/1`)).data).toEqual(first)
  expect((await request(page, `splice-report/${first.id}/versions`, 'POST', { name: first.name, baseVersionNo: 2, changeSummary: '伪造指标', settings: { ...first.settings, statisticValues: { '999|999|3|finalVoltage1': '7' } } })).code).not.toBe(200)
})
 
test('浏览器从真实来源新建多电池拼接,重排、模板、人工调整、重开及打印', async ({ page }) => {
  await login(page)
  const errors: string[] = []
  page.on('pageerror', error => errors.push(error.message))
  await page.goto('/report-splice/new')
  await page.getByPlaceholder('请输入拼接报表名称').fill('浏览器多电池拼接')
  for (const name of ['连续来源3', '连续来源2']) {
    await page.locator('.panel-actions').getByRole('button', { name: '添加拼接', exact: true }).click()
    const dialog = page.getByRole('dialog', { name: '添加拼接', exact: true })
    await dialog.getByPlaceholder('输入测试名称或ID搜索').fill(name)
    await dialog.getByRole('combobox').last().click()
    await page.getByRole('option', { name: new RegExp(name) }).click()
    await dialog.getByRole('button', { name: '添加拼接', exact: true }).click()
    await expect(dialog).not.toBeVisible()
  }
  await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源2')
  await page.getByRole('button', { name: '下移测试', exact: true }).first().click()
  await expect(page.locator('.test-table tbody tr').first()).toContainText('连续来源3')
  await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10)
  await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
  await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5)
  await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
  await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(10)
  await expect(page.locator('.splice-snapshot')).not.toContainText('SP2')
  await expect(page.locator('.splice-chart')).toHaveCount(2)
  await page.getByRole('button', { name: '字段设置', exact: true }).click()
  const fieldDialog = page.getByRole('dialog', { name: '字段设置', exact: true })
  const fRow = fieldDialog.locator('tbody tr').filter({ has: page.getByRole('checkbox', { name: '选择F', exact: true }) })
  await fRow.locator('.el-checkbox').click()
  await fRow.getByRole('textbox', { name: '展示名称', exact: true }).fill('冻结业务F')
  await fieldDialog.getByRole('button', { name: '确认', exact: true }).click()
  await expect(page.locator('.detail-table tbody tr').first().locator('input').first()).toHaveValue(/^(12.5|25)$/)
  await page.getByRole('button', { name: '选择字段模板', exact: true }).click()
  await page.getByRole('button', { name: '保存为模板', exact: true }).click()
  const saveTemplate = page.getByRole('dialog', { name: '保存拼接字段模板', exact: true })
  await saveTemplate.getByRole('textbox').fill('浏览器拼接模板')
  await saveTemplate.getByRole('button', { name: '保存', exact: true }).click()
  await expect(page.getByText('字段模板已保存', { exact: true })).toBeVisible()
  await page.getByRole('dialog', { name: '选择拼接字段模板', exact: true }).getByRole('button', { name: '应用模板', exact: true }).click()
  await page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true }).fill('-1.25')
  await page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true }).fill('9')
  await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
  await expect(page.locator('.statistics-editor tbody tr')).toHaveCount(5)
  await page.locator('.panel-actions .el-checkbox').filter({ hasText: '第二电池' }).click()
  await expect(page.getByRole('textbox', { name: '连续来源3 第二电池 循环3 最终电压1', exact: true })).toHaveValue('')
  await expect(page.getByRole('textbox', { name: '连续来源3 同名电池 循环3 最终电压1', exact: true })).toHaveValue('-1.25')
  for (const width of [1440, 390]) {
    await page.setViewportSize({ width, height: 900 })
    await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
    await page.screenshot({ path: `tests/e2e/.artifacts/ticket11-new-${width}.png`, fullPage: true })
  }
  const response = page.waitForResponse(response => response.url().endsWith('/system/splice-report') && response.request().method() === 'POST')
  await page.getByRole('button', { name: '保存报表', exact: true }).click()
  const saved = (await (await response).json()).data
  expect(saved.selections).toEqual([selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)])
  expect(saved.settings.fieldStructure[0].displayName).toBe('冻结业务F')
  await expect(page).toHaveURL(/\/report-splice$/)
  await page.getByPlaceholder('输入拼接名称或ID').fill(saved.name)
  await page.getByRole('button', { name: '查询', exact: true }).click()
  const reportRow = page.locator('tbody tr').filter({ hasText: saved.name })
  await expect(reportRow.locator('td').nth(2)).toHaveText(/^\d{4}-\d{2}-\d{2} /)
  await reportRow.getByRole('button', { name: '查看报表', exact: true }).click()
  await page.reload()
  await expect(page.locator('.splice-snapshot')).toContainText('-1.25')
  await expect(page.locator('.splice-snapshot')).toContainText('计算原值:缺失')
  await page.context().addInitScript(() => { window.print = () => { document.body.dataset.printRequested = 'true' } })
  const popupPromise = page.waitForEvent('popup')
  await page.getByRole('button', { name: '打印 / 另存 PDF', exact: true }).click()
  const popup = await popupPromise
  await expect(popup.locator('body')).toHaveAttribute('data-print-requested', 'true')
  expect(await popup.locator('.splice-snapshot').innerText()).toEqual(await page.locator('.splice-snapshot').innerText())
  await popup.pdf({ path: 'tests/e2e/.artifacts/ticket11-multi-battery.pdf', preferCSSPageSize: true, printBackground: true })
  await popup.close()
  await page.goto(`/report-splice/${saved.id}/edit`)
  await page.getByRole('button', { name: '按最新来源生成新版本', exact: true }).click()
  const refreshDialog = page.getByRole('dialog', { name: '按最新来源生成新版本', exact: true })
  await refreshDialog.getByRole('textbox').fill('浏览器主动更新')
  await refreshDialog.getByRole('button', { name: '生成新版本', exact: true }).click()
  await expect(page.getByText('已按最新来源生成新版本', { exact: true })).toBeVisible()
  expect((await api(page, `splice-report/${saved.id}/versions/1`)).data).toEqual(saved)
  expect(errors).toEqual([])
})
 
test('各类五动作权限可配置且不能跨类别使用,查看用户只能查看跨创建人报表', async ({ page }) => {
  await login(page)
  const modules = (await api(page, 'menu/permissionList')).data.flatMap((module: any) => module.pages)
  const names = { compare: '对比报表', formation: '化成报表', splice: '拼接报表' }
  const saved: Partial<Record<Kind, any>> = {}
  for (const kind of kinds) {
    saved[kind] = (await api(page, `${kind}-report`, 'POST', {
      ...payload(kind, `权限验收-${kind}`),
      selections: kind === 'compare' ? compareSelections.filter(item => item.testId === 9303) : [selection(9302), selection(9303)],
    })).data
    const permissions = modules.find((module: any) => module.pageName === names[kind]).permissions
    for (const action of ['查看', '新增', '编辑', '删除', '导出']) {
      expect.soft(permissions.filter((item: any) => item.menuName === action), `${names[kind]} ${action} 必须可通过正式权限页配置`).toHaveLength(1)
    }
    const username = `ticket13_${kind}`
    await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.filter((item: any) => item.menuName === '查看').map((item: any) => item.menuId) })
    const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
    await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
  }
  for (const kind of kinds) {
    await login(page, `ticket13_${kind}`)
    await api(page, `${kind}-report/list`)
    expect((await api(page, `${kind}-report/${saved[kind].id}`)).data).toEqual(saved[kind])
    for (const target of kinds) {
      if (target !== kind) expect((await request(page, `${target}-report/list`)).code).toBe(403)
      for (const [path, method, data] of [
        [`${target}-report`, 'POST', payload(target, '无权限新建')],
        [`${target}-report/1/versions`, 'POST', { ...payload(target, '无权限编辑'), selections: undefined, baseVersionNo: 1, changeSummary: '拒绝' }],
        [`${target}-report/1`, 'DELETE', undefined],
        [`${target}-report/1/versions/1/print`, 'GET', undefined],
      ] as const) expect((await request(page, path, method, data)).code, path).toBe(403)
    }
    await page.goto(`/report-${kind}`)
    await expect(page.getByRole('button', { name: /新建|新增/ })).toHaveCount(0)
    await expect(page.locator('tbody').getByRole('button', { name: /^编辑(报表)?$/ })).toHaveCount(0)
    await expect(page.locator('tbody').getByRole('button', { name: '删除', exact: true })).toHaveCount(0)
  }
  for (const kind of kinds) {
    await login(page)
    const username = `t13_${kind}_actor`
    const permissions = modules.find((module: any) => module.pageName === names[kind]).permissions
    await api(page, 'role', 'POST', { roleName: username, roleKey: 'common', roleSort: 1, status: '0', menuIds: permissions.map((item: any) => item.menuId) })
    const role = (await api(page, `role/list?roleName=${username}`)).rows[0]
    await api(page, 'user', 'POST', { userName: username, nickName: username, password: 'Report123!', status: '0', roleIds: [role.roleId] })
    await login(page, username)
    const first = saved[kind]
    await api(page, `${kind}-report/list`)
    await api(page, `${kind}-report/${first.id}`)
    const edited = (await api(page, `${kind}-report/${first.id}/versions`, 'POST', {
      ...payload(kind, `跨创建人-${kind}`), settings: first.settings, selections: undefined, baseVersionNo: 1, changeSummary: '有权限的其他用户编辑',
    })).data
    expect(edited.operatorName).toBe(username)
    expect((await api(page, `${kind}-report/${first.id}/versions/1/print`)).data).toEqual(first)
    const own = (await api(page, `${kind}-report`, 'POST', {
      ...payload(kind, `授权创建-${kind}`),
      selections: kind === 'compare' ? compareSelections.filter(item => item.testId === 9303) : [selection(9302), selection(9303)],
    })).data
    const templateFields = kind === 'formation'
      ? [{ key: '人工验收项', scope: 'detail', kind: 'manual', displayName: '人工验收项', unit: '' }]
      : [{ name: '人工验收项', category: 'special', displayName: '人工验收项', unit: '' }]
    const createdTemplate = await api(page, `${kind}-report/templates`, 'POST', { name: `类型隔离-${kind}`, fields: templateFields })
    await api(page, `${kind}-report/templates/${createdTemplate.id ?? createdTemplate.data}`, 'PUT', { name: `类型隔离更新-${kind}`, fields: templateFields })
    expect((await api(page, `${kind}-report/${first.id}/versions/1`)).data).toEqual(first)
    for (const target of kinds.filter(value => value !== kind)) expect((await request(page, `${target}-report/templates`)).code).toBe(403)
    await page.goto(`/report-${kind}`)
    await expect(page.getByRole('button', { name: /新建|新增/ })).toBeVisible()
    const row = page.locator('tbody tr').filter({ hasText: `跨创建人-${kind}` })
    await expect(row.getByRole('button', { name: /^编辑(报表)?$/ })).toBeVisible()
    await expect(row.getByRole('button', { name: '删除', exact: true })).toBeVisible()
    await api(page, `${kind}-report/${first.id}`, 'DELETE')
    await api(page, `${kind}-report/${own.id}`, 'DELETE')
  }
})
 
test('工步与项目图表仍使用真实持久化数据,筛选、显隐及详情重开正常', async ({ page }) => {
  await login(page)
  const before = (await api(page, 'test/9303/data/records')).data
  await page.goto('/test-list/9303/console?tab=step-chart')
  await expect(page.locator('.metric-chart canvas')).toHaveCount(5)
  await page.locator('.detail-meta input').fill('Ticket13 工步详情回归')
  await page.getByRole('button', { name: '保存详情', exact: true }).click()
  await expect(page.getByText('测试详情已保存', { exact: true })).toBeVisible()
  await page.reload()
  await expect(page.locator('.detail-meta input')).toHaveValue('Ticket13 工步详情回归')
  await page.locator('.data-select-panel .step-select').first().locator('.el-select').click()
  await page.getByRole('option', { name: '循环 3', exact: true }).click()
  await expect(page.locator('.battery-range-row .range-label').first()).toContainText('循环 3')
  await page.locator('.metric-switches label').filter({ hasText: '温度' }).click()
  await expect(page.locator('.metric-chart canvas')).toHaveCount(4)
  await page.locator('.metric-switches label').filter({ hasText: '温度' }).click()
  await expect(page.locator('.metric-chart canvas')).toHaveCount(5)
  await expect.poll(async () => page.locator('.metric-chart canvas').first().evaluate((canvas: HTMLCanvasElement) => {
    const context = canvas.getContext('2d')!
    const data = context.getImageData(0, 0, canvas.width, canvas.height).data
    let colored = 0
    for (let i = 0; i < data.length; i += 4) if (data[i + 3]! > 0 && Math.max(data[i]!, data[i + 1]!, data[i + 2]!) - Math.min(data[i]!, data[i + 1]!, data[i + 2]!) > 50) colored++
    return colored
  })).toBeGreaterThan(50)
  await page.screenshot({ path: 'tests/e2e/.artifacts/ticket13-step-chart-regression.png', fullPage: true })
  await page.getByRole('button', { name: '项目图表', exact: true }).click()
  await expect(page.locator('.project-chart canvas')).toHaveCount(3)
  expect((await api(page, 'test/9303/data/records')).data).toEqual(before)
})
 
test('多电池循环覆盖不同,图上来源分界仍位于该测试最早拼接循环', async ({ page }) => {
  await login(page)
  for (const table of ['t_test_realtime_record', 't_test_step_record', 't_test_cycle_record']) {
    stack.executeIsolatedSql(`DELETE FROM ${table} WHERE (test_battery_id=9403 AND cycle_no=1) OR (test_battery_id=9402 AND cycle_no=5)`)
  }
  const saved = (await api(page, 'splice-report', 'POST', {
    ...payload('splice', '不同循环覆盖的来源分界'),
    selections: [selection(9303), selection(9303, 9403), selection(9302), selection(9302, 9402)],
  })).data
  expect(saved.segments.map((segment: any) => segment.originalCycleNos)).toEqual([[1, 3, 5], [3, 5], [5, 7], [7]])
  expect(saved.segments.map((segment: any) => segment.offset)).toEqual([0, 0, 1, 1])
  await page.goto(`/report-splice/${saved.id}/edit?mode=view`)
  const lines = page.locator('.splice-chart').first().locator('svg path[stroke="#82909b"]')
  await expect(lines).toHaveCount(2)
  const positions = await lines.evaluateAll(lines => lines.map(line => (line as SVGPathElement).getPointAtLength(0).x))
  // 固定900像素图中循环1、6位于122和622附近;允许描边对齐的半像素偏移。
  expect(Math.abs(positions[0]! - 122)).toBeLessThanOrEqual(1)
  expect(Math.abs(positions[1]! - 622)).toBeLessThanOrEqual(1)
  await page.screenshot({ path: 'tests/e2e/.artifacts/ticket11-asymmetric-cycles.png', fullPage: true })
})