From 96a50dc7b683955d962c239b1685d00d2f38b2f7 Mon Sep 17 00:00:00 2001 From: wufangyong973 Date: Fri, 2 Oct 2026 11:46:07 +0800 Subject: [PATCH] feat(scenarios): add meter-value-negative to the corpus METER_VALUE_ANOMALY has two branches and only the decreasing one was exercised. This trace drives the register to -50 Wh, which a cumulative register can never read, so the negative check now has a regression guard. Registers in index.ts and index.test.ts, bumps the external fixture and both README counts, and adds a patch changeset. --- .changeset/add-meter-value-negative.md | 5 + README.md | 2 +- packages/toolkit/README.md | 2 +- .../__scenarios__/meter-value-negative.ts | 114 ++++++++++++++++++ packages/toolkit/src/scenarios/index.test.ts | 15 ++- packages/toolkit/src/scenarios/index.ts | 5 + tests/external-fixture/test.mjs | 4 +- 7 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 .changeset/add-meter-value-negative.md create mode 100644 packages/toolkit/src/scenarios/__scenarios__/meter-value-negative.ts diff --git a/.changeset/add-meter-value-negative.md b/.changeset/add-meter-value-negative.md new file mode 100644 index 0000000..c83360b --- /dev/null +++ b/.changeset/add-meter-value-negative.md @@ -0,0 +1,5 @@ +--- +'@ocpp-debugkit/toolkit': patch +--- + +meter-value-negative: add a scenario that drives the negative branch of METER_VALUE_ANOMALY. The register reads -50 Wh, which is physically impossible, so the rule fires. meter-anomaly only covers the decreasing branch, so this is the missing half. diff --git a/README.md b/README.md index 13eb7f0..4bb1527 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ validate behavior against known scenarios. firmware update failures, suspicious session duration, slow CSMS responses, heartbeat interval violations, meter value anomalies, unresponsive CSMS, and repeated boot notifications. -- **Scenario Evaluator** — 23 predefined scenarios with expected failure +- **Scenario Evaluator** — 24 predefined scenarios with expected failure outcomes for testing the analysis engine. Supports external scenario files. - **Replay Engine** — Deterministic, pure replay engine with step forward/back, jump-to-event, and configurable playback speed. diff --git a/packages/toolkit/README.md b/packages/toolkit/README.md index e7dc15c..c30398d 100644 --- a/packages/toolkit/README.md +++ b/packages/toolkit/README.md @@ -20,7 +20,7 @@ report generation, React components, and CLI. firmware update failures, suspicious session duration, slow CSMS responses, heartbeat interval violations, meter value anomalies, unresponsive CSMS, and repeated boot notifications. -- **Scenario Evaluator** — 23 predefined scenarios with expected failure +- **Scenario Evaluator** — 24 predefined scenarios with expected failure outcomes for testing the analysis engine. Supports external scenario files. - **Replay Engine** — Deterministic, pure replay engine with step forward/back, jump-to-event, and configurable playback speed. No timers or I/O. diff --git a/packages/toolkit/src/scenarios/__scenarios__/meter-value-negative.ts b/packages/toolkit/src/scenarios/__scenarios__/meter-value-negative.ts new file mode 100644 index 0000000..323b87b --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/meter-value-negative.ts @@ -0,0 +1,114 @@ +export default { + name: 'meter-value-negative', + description: + 'Cumulative energy register goes negative during a transaction. A negative reading is physically impossible and triggers METER_VALUE_ANOMALY.', + trace: { + traceId: 'scenario-meter-value-negative', + metadata: { + stationId: 'CS-SYNTHETIC-025', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'Station reports a negative cumulative register (-50 Wh) — the register can never go below zero.', + }, + events: [ + { + timestamp: '2026-03-01T09:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-025', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2026-03-01T09:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-001', { currentTime: '2026-03-01T09:00:00.500Z', interval: 300, status: 'Accepted' }], + }, + { + timestamp: '2026-03-01T09:00:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-002', 'Authorize', { idTag: 'SYNTHETIC-TAG-025' }], + }, + { + timestamp: '2026-03-01T09:00:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', { idTagInfo: { status: 'Accepted' } }], + }, + { + timestamp: '2026-03-01T09:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'StartTransaction', + { connectorId: 1, idTag: 'SYNTHETIC-TAG-025', meterStart: 0 }, + ], + }, + { + timestamp: '2026-03-01T09:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-003', { idTagInfo: { status: 'Accepted' }, transactionId: 100025 }], + }, + { + timestamp: '2026-03-01T09:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'MeterValues', + { + connectorId: 1, + transactionId: 100025, + meterValue: [ + { + sampledValue: [ + { value: '-50', measurand: 'Energy.Active.Import.Register', unit: 'Wh' }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2026-03-01T09:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-004', {}], + }, + { + timestamp: '2026-03-01T09:05:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-005', + 'StopTransaction', + { + transactionId: 100025, + idTag: 'SYNTHETIC-TAG-025', + meterStop: 0, + reason: 'Local', + }, + ], + }, + { + timestamp: '2026-03-01T09:05:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-005', { idTagInfo: { status: 'Accepted' } }], + }, + ], + }, + expectedFailures: ['METER_VALUE_ANOMALY'], + assertions: [ + { + type: 'failure_count', + params: { code: 'METER_VALUE_ANOMALY', min: 1 }, + }, + ], +}; diff --git a/packages/toolkit/src/scenarios/index.test.ts b/packages/toolkit/src/scenarios/index.test.ts index 0f754b7..6c67591 100644 --- a/packages/toolkit/src/scenarios/index.test.ts +++ b/packages/toolkit/src/scenarios/index.test.ts @@ -26,6 +26,7 @@ import { meterValueZeroScenario, statusTransitionsLegalScenario, bootOutsideRepeatWindowScenario, + meterValueNegativeScenario, } from './index.js'; import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js'; @@ -34,8 +35,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index. // --------------------------------------------------------------------------- describe('scenario registry', () => { - it('exports exactly 23 scenarios', () => { - expect(scenarios).toHaveLength(23); + it('exports exactly 24 scenarios', () => { + expect(scenarios).toHaveLength(24); }); it('exports scenario names in order', () => { @@ -63,6 +64,7 @@ describe('scenario registry', () => { 'meter-value-zero', 'status-transitions-legal', 'boot-outside-repeat-window', + 'meter-value-negative', ]); }); @@ -99,6 +101,7 @@ describe('scenario registry', () => { expect(getScenario('meter-value-zero')).toBe(meterValueZeroScenario); expect(getScenario('status-transitions-legal')).toBe(statusTransitionsLegalScenario); expect(getScenario('boot-outside-repeat-window')).toBe(bootOutsideRepeatWindowScenario); + expect(getScenario('meter-value-negative')).toBe(meterValueNegativeScenario); }); it('getScenario returns undefined for unknown name', () => { @@ -278,6 +281,14 @@ describe('scenario engine integration', () => { const failures = detectFailures(result.events, sessions); expect(failures).toHaveLength(0); }); + + it('meter-value-negative: detects METER_VALUE_ANOMALY', () => { + const trace = JSON.stringify(meterValueNegativeScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'METER_VALUE_ANOMALY')).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/toolkit/src/scenarios/index.ts b/packages/toolkit/src/scenarios/index.ts index c95975d..cc58f79 100644 --- a/packages/toolkit/src/scenarios/index.ts +++ b/packages/toolkit/src/scenarios/index.ts @@ -28,6 +28,7 @@ import repeatedBootNotification from './__scenarios__/repeated-boot-notification import meterValueZero from './__scenarios__/meter-value-zero.js'; import statusTransitionsLegal from './__scenarios__/status-transitions-legal.js'; import bootOutsideRepeatWindow from './__scenarios__/boot-outside-repeat-window.js'; +import meterValueNegative from './__scenarios__/meter-value-negative.js'; // --------------------------------------------------------------------------- // Scenarios derived from core fixtures @@ -82,6 +83,7 @@ const repeatedBootNotificationScenario: Scenario = repeatedBootNotification as u const meterValueZeroScenario: Scenario = meterValueZero as unknown as Scenario; const statusTransitionsLegalScenario: Scenario = statusTransitionsLegal as unknown as Scenario; const bootOutsideRepeatWindowScenario: Scenario = bootOutsideRepeatWindow as unknown as Scenario; +const meterValueNegativeScenario: Scenario = meterValueNegative as unknown as Scenario; // --------------------------------------------------------------------------- // Registry @@ -111,6 +113,7 @@ export const scenarios = [ meterValueZeroScenario, statusTransitionsLegalScenario, bootOutsideRepeatWindowScenario, + meterValueNegativeScenario, ] as const; export const scenarioNames = [ @@ -137,6 +140,7 @@ export const scenarioNames = [ 'meter-value-zero', 'status-transitions-legal', 'boot-outside-repeat-window', + 'meter-value-negative', ] as const; export { @@ -163,6 +167,7 @@ export { meterValueZeroScenario, statusTransitionsLegalScenario, bootOutsideRepeatWindowScenario, + meterValueNegativeScenario, }; export { compareScenarioReports } from './compare.js'; diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs index a5613aa..e7c66cc 100644 --- a/tests/external-fixture/test.mjs +++ b/tests/external-fixture/test.mjs @@ -158,8 +158,8 @@ const scenarios = await import('@ocpp-debugkit/toolkit/scenarios'); assert(Array.isArray(scenarios.scenarios), 'scenarios is an array'); assert( - scenarios.scenarios.length === 23, - `23 scenarios exported (got ${scenarios.scenarios.length})`, + scenarios.scenarios.length === 24, + `24 scenarios exported (got ${scenarios.scenarios.length})`, ); assert(typeof scenarios.getScenario === 'function', 'getScenario is a function'); assert(scenarios.getScenario('normal-session') !== undefined, 'normal-session scenario exists');