Browse Source

test(cockpit): 逐节点对照真实采集源校验最新指标回归

ChenJZ 18 hours ago
parent
commit
015982dbf8

+ 56 - 4
.scratch/cockpit-shell-panel-layout/verify/t56-latest-live.mjs

@@ -5,14 +5,66 @@ import { metricRows, sectionHasContent, sectionPlaceholder } from '../../../src/
 const baseUrl = process.argv[2]
 assert.ok(baseUrl, 'Pass the existing cockpit read-only API base URL')
 const deviceCode = process.argv[3] || '21119740'
+const request = (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(15000) })
+// Independent existing source API, rather than copying the cockpit adapter's query.
+const factors = [
+    { node: 1, code: 'ENV_SUSPENDED_SOLIDS', name: '悬浮物', field: 'floatValue', unit: 'mg/L' },
+    { node: 2, code: 'ENV_COD', name: 'COD', field: 'hum', unit: 'mg/L' },
+    { node: 3, code: 'ENV_AMMONIA_NITROGEN', name: '氨氮', field: 'hum', unit: 'mg/L' },
+    { node: 4, code: 'ENV_CONDUCTIVITY', name: '电导率', field: 'hum', unit: 'μS/cm' },
+    { node: 5, code: 'ENV_PH', name: 'pH', field: 'hum', unit: '' },
+]
+const sources = deviceCode === '21119740' ? await Promise.all(factors.map(async (factor) => {
+    const url = new URL('/api/env/visualization/latest', baseUrl)
+    url.searchParams.set('deviceId', deviceCode)
+    url.searchParams.set('nodeId', String(factor.node))
+    const response = await request(url)
+    assert.ok(response.ok, `Source node ${factor.node}: HTTP ${response.status}`)
+    const body = await response.json()
+    assert.equal(body.code, 200, `Source node ${factor.node} must succeed independently`)
+    const record = body.data || null
+    if (record) {
+        assert.equal(record.deviceId, Number(deviceCode), 'Source device identity mismatch')
+        assert.equal(record.nodeId, factor.node, 'Source node identity mismatch')
+    }
+    return { factor, record }
+})) : []
 const result = await fetchEquipmentDetailAggregate(deviceCode, {
-    config: { dataSource: 'real', api: { baseUrl } },
-    fetchImpl: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(15000) }),
+    config: { dataSource: 'real', api: { baseUrl } }, fetchImpl: request,
 })
 const metrics = result.data?.sections?.metrics
 const rows = metricRows(metrics?.data)
-console.log(JSON.stringify({ deviceCode, aggregate: result.state, metrics: metrics?.state, placeholder: sectionPlaceholder(metrics), rows }, null, 2))
+console.log(JSON.stringify({ deviceCode, aggregate: result.state, metrics: metrics?.state,
+    placeholder: sectionPlaceholder(metrics), rows,
+    sourceNodes: sources.map(({ factor, record }) => ({ node: factor.node, recordId: record?.id,
+        value: record?.[factor.field], temperature: record?.tem, createdAt: record?.createTime })),
+}, null, 2))
 assert.ok(sectionHasContent(metrics), `${deviceCode}: latest metrics must render content, not ${sectionPlaceholder(metrics)}`)
 assert.ok(rows.some((row) => row.value !== '—'), `${deviceCode}: expected at least one real reading`)
 assert.ok(rows.every((row) => row.name && row.sampledAt), 'Every metric must retain its name and source time')
-console.log('PASS real detail API -> latest metric rows')
+
+function assertReading(code, name, value, unit, sourceTime) {
+    const row = rows.find((item) => item.code === code)
+    assert.ok(row, `Missing real source metric ${code}`)
+    assert.equal(row.name, name)
+    assert.equal(row.unit, unit, `${code}: source unit mismatch`)
+    const missing = value === null || value === undefined || String(value).trim() === '' || !Number.isFinite(Number(value))
+    if (missing) assert.equal(row.value, '—', `${code}: missing source value must not become zero`)
+    else assert.equal(Number(row.value), Number(value), `${code}: source and cockpit readings differ`)
+    assert.equal(row.sampledAtLabel, '数据入库时间', `${code}: create_time must not be called sampling time`)
+    const sourceAt = /(?:Z|[+-]\d{2}:\d{2})$/.test(sourceTime) ? sourceTime : `${sourceTime}+08:00`
+    const sourceMillis = Date.parse(sourceAt)
+    const metricMillis = Date.parse(row.sampledAt)
+    assert.ok(Number.isFinite(sourceMillis), `${code}: invalid source timestamp`)
+    assert.ok(Number.isFinite(metricMillis), `${code}: invalid cockpit timestamp`)
+    assert.equal(metricMillis, sourceMillis, `${code}: latest node source time differs`)
+}
+for (const { factor, record } of sources) {
+    if (!record) {
+        assert.ok(!rows.some((row) => row.code === factor.code), `Node ${factor.node}: no source must not produce a reading`)
+        continue
+    }
+    assertReading(factor.code, factor.name, record[factor.field], factor.unit, record.createTime)
+    assertReading(`ENV_NODE_${factor.node}_TEMPERATURE`, `${factor.name}节点温度`, record.tem, '℃', record.createTime)
+}
+console.log('PASS real detail API -> latest metric rows, independently checked against each available source node')

+ 31 - 0
.scratch/cockpit-shell-panel-layout/verify/t56-live-fixture.mjs

@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict'
+
+// This replaces only HTTP transport. The real detail API, adapter and row view still run.
+const scenario = process.env.T56_LIVE_SCENARIO || 'normal'
+const value = scenario === 'zero' ? 0 : scenario === 'missing' ? null : 17.3
+const time = scenario === 'invalid-time' ? 'not-a-timestamp' : '2026-10-23T17:04:29'
+const source = { id: 'captured-node-2', deviceId: 21119740, nodeId: 2, hum: value, tem: 22.2, createTime: time }
+if (scenario === 'wrong-node') source.nodeId = 3
+const metrics = [
+    { metricCode: 'ENV_COD', metricName: 'COD', value, unit: scenario === 'wrong-unit' ? 'MPa' : 'mg/L',
+        sampledAt: time === 'not-a-timestamp' ? time : `${time}+08:00`, timeSource: 'CREATED_AT', freshness: 'STALE' },
+    { metricCode: 'ENV_NODE_2_TEMPERATURE', metricName: 'COD节点温度', value: 22.2, unit: '℃',
+        sampledAt: time === 'not-a-timestamp' ? time : `${time}+08:00`, timeSource: 'CREATED_AT', freshness: 'STALE' },
+]
+// phantom-temperature:源记录在,但后端漏发该节点的温度指标行。
+if (scenario === 'phantom-temperature') metrics.pop()
+const reply = (data) => new Response(JSON.stringify({ code: 200, data }), { headers: { 'Content-Type': 'application/json' } })
+globalThis.fetch = async (input) => {
+    const url = new URL(input)
+    assert.equal(url.hostname, 'fixture.invalid', 'The offline guard must never contact a live host')
+    if (url.pathname === '/api/env/visualization/latest') {
+        assert.equal(url.searchParams.get('deviceId'), '21119740')
+        // no-source:源侧无记录;其余场景仅节点 2 有捕获记录。
+        return reply(url.searchParams.get('nodeId') === '2' && scenario !== 'no-source' ? source : null)
+    }
+    assert.equal(url.pathname, '/api/cockpit/v1/equipments/21119740/detail-aggregate')
+    return reply({ deviceCode: '21119740', aggregateState: 'READY',
+        archive: { state: 'SUCCESS', data: { deviceCode: '21119740', deviceName: '环境设备', deviceType: '15', deviceTypeName: '仁科环境监测器' } },
+        metrics: { state: 'STALE', data: { deviceCode: '21119740', state: 'STALE', stateReason: 'SAMPLE_OUTDATED', metrics } },
+    })
+}

+ 23 - 0
.scratch/cockpit-shell-panel-layout/verify/t56-live-verifier-guards.mjs

@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict'
+import { spawnSync } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+const fixture = new URL('./t56-live-fixture.mjs', import.meta.url).href
+const verifier = fileURLToPath(new URL('./t56-latest-live.mjs', import.meta.url))
+const cases = [
+    ['normal', null], ['zero', null], ['missing', null],
+    ['wrong-unit', /source unit mismatch/],
+    ['wrong-node', /Source node identity mismatch/],
+    ['invalid-time', /invalid source timestamp/],
+    ['phantom-temperature', /Missing real source metric ENV_NODE_2_TEMPERATURE/],
+    ['no-source', /Node 2: no source must not produce a reading/],
+]
+for (const [scenario, expectedFailure] of cases) {
+    const result = spawnSync(process.execPath, ['--import', fixture, verifier, 'http://fixture.invalid/api/cockpit/v1'], {
+        encoding: 'utf8', timeout: 15000, env: { ...process.env, T56_LIVE_SCENARIO: scenario },
+    })
+    assert.ifError(result.error)
+    assert.equal(result.status, expectedFailure ? 1 : 0, `${scenario}: ${result.stderr}`)
+    if (expectedFailure) assert.match(result.stderr, expectedFailure, `${scenario}: failure must identify the actual assertion`)
+    console.log(`PASS ${scenario}: ${expectedFailure ? 'expected assertion caught' : 'source readings match'}`)
+}
+console.log(`PASS live-verifier guards: ${cases.length}/${cases.length} offline transport scenarios`)