diff --git a/.changeset/brave-pandas-count.md b/.changeset/brave-pandas-count.md new file mode 100644 index 0000000..27007df --- /dev/null +++ b/.changeset/brave-pandas-count.md @@ -0,0 +1,5 @@ +--- +'@ocpp-debugkit/toolkit': patch +--- + +Add a meter-value-zero scenario to ensure a flat zero meter register does not report a failure. diff --git a/README.md b/README.md index 09862a3..c71f59c 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** — 19 predefined scenarios with expected failure +- **Scenario Evaluator** — 20 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 127d75f..68dfafd 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** — 19 predefined scenarios with expected failure +- **Scenario Evaluator** — 20 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-zero.ts b/packages/toolkit/src/scenarios/__scenarios__/meter-value-zero.ts new file mode 100644 index 0000000..f52326c --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/meter-value-zero.ts @@ -0,0 +1,163 @@ +export default { + name: 'meter-value-zero', + description: + 'Transaction with a flat zero meter register throughout. A zero reading is a valid negative control and must not trigger METER_VALUE_ANOMALY.', + trace: { + traceId: 'scenario-meter-value-zero', + metadata: { + stationId: 'CS-SYNTHETIC-021', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'Station completes a four-minute transaction while the cumulative energy register remains at zero.', + }, + events: [ + { + timestamp: '2026-01-15T09:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-021', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2026-01-15T09:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { currentTime: '2026-01-15T09:00:00.500Z', interval: 300, status: 'Accepted' }, + ], + }, + { + timestamp: '2026-01-15T09:00:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-002', 'Authorize', { idTag: 'SYNTHETIC-TAG-021' }], + }, + { + timestamp: '2026-01-15T09:00:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', { idTagInfo: { status: 'Accepted' } }], + }, + { + timestamp: '2026-01-15T09:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'StartTransaction', + { connectorId: 1, idTag: 'SYNTHETIC-TAG-021', meterStart: 0 }, + ], + }, + { + timestamp: '2026-01-15T09:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-003', { idTagInfo: { status: 'Accepted' }, transactionId: 100021 }], + }, + { + timestamp: '2026-01-15T09:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'MeterValues', + { + connectorId: 1, + transactionId: 100021, + meterValue: [ + { + sampledValue: [ + { value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2026-01-15T09:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-004', {}], + }, + { + timestamp: '2026-01-15T09:03:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-005', + 'MeterValues', + { + connectorId: 1, + transactionId: 100021, + meterValue: [ + { + sampledValue: [ + { value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2026-01-15T09:03:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-005', {}], + }, + { + timestamp: '2026-01-15T09:04:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-006', + 'MeterValues', + { + connectorId: 1, + transactionId: 100021, + meterValue: [ + { + sampledValue: [ + { value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2026-01-15T09:04:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-006', {}], + }, + { + timestamp: '2026-01-15T09:05:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-007', + 'StopTransaction', + { + transactionId: 100021, + idTag: 'SYNTHETIC-TAG-021', + meterStop: 0, + reason: 'Local', + }, + ], + }, + { + timestamp: '2026-01-15T09:05:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-007', { idTagInfo: { status: 'Accepted' } }], + }, + ], + }, + expectedFailures: [], + assertions: [{ type: 'no_failures', params: {} }], +}; diff --git a/packages/toolkit/src/scenarios/index.test.ts b/packages/toolkit/src/scenarios/index.test.ts index aff5251..a04b3a2 100644 --- a/packages/toolkit/src/scenarios/index.test.ts +++ b/packages/toolkit/src/scenarios/index.test.ts @@ -22,6 +22,7 @@ import { firmwareUpdateFailureScenario, refusedAuthorizationScenario, heartbeatTimeoutScenario, + meterValueZeroScenario, } from './index.js'; import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js'; @@ -30,8 +31,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index. // --------------------------------------------------------------------------- describe('scenario registry', () => { - it('exports exactly 19 scenarios', () => { - expect(scenarios).toHaveLength(19); + it('exports exactly 20 scenarios', () => { + expect(scenarios).toHaveLength(20); }); it('exports scenario names in order', () => { @@ -55,6 +56,7 @@ describe('scenario registry', () => { 'firmware-update-failure', 'refused-authorization', 'heartbeat-timeout', + 'meter-value-zero', ]); }); @@ -87,6 +89,7 @@ describe('scenario registry', () => { expect(getScenario('firmware-update-failure')).toBe(firmwareUpdateFailureScenario); expect(getScenario('refused-authorization')).toBe(refusedAuthorizationScenario); expect(getScenario('heartbeat-timeout')).toBe(heartbeatTimeoutScenario); + expect(getScenario('meter-value-zero')).toBe(meterValueZeroScenario); }); it('getScenario returns undefined for unknown name', () => { @@ -242,6 +245,14 @@ describe('scenario engine integration', () => { const failures = detectFailures(result.events, sessions); expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true); }); + + it('meter-value-zero: no failures detected', () => { + const trace = JSON.stringify(meterValueZeroScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures).toHaveLength(0); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/toolkit/src/scenarios/index.ts b/packages/toolkit/src/scenarios/index.ts index 801945a..db6c2a1 100644 --- a/packages/toolkit/src/scenarios/index.ts +++ b/packages/toolkit/src/scenarios/index.ts @@ -24,6 +24,7 @@ import firmwareUpdateSuccess from './__scenarios__/firmware-update-success.js'; import firmwareUpdateFailure from './__scenarios__/firmware-update-failure.js'; import refusedAuthorization from './__scenarios__/refused-authorization.js'; import heartbeatTimeout from './__scenarios__/heartbeat-timeout.js'; +import meterValueZero from './__scenarios__/meter-value-zero.js'; // --------------------------------------------------------------------------- // Scenarios derived from core fixtures @@ -74,6 +75,7 @@ const firmwareUpdateSuccessScenario: Scenario = firmwareUpdateSuccess as unknown const firmwareUpdateFailureScenario: Scenario = firmwareUpdateFailure as unknown as Scenario; const refusedAuthorizationScenario: Scenario = refusedAuthorization as unknown as Scenario; const heartbeatTimeoutScenario: Scenario = heartbeatTimeout as unknown as Scenario; +const meterValueZeroScenario: Scenario = meterValueZero as unknown as Scenario; // --------------------------------------------------------------------------- // Registry @@ -99,6 +101,7 @@ export const scenarios = [ firmwareUpdateFailureScenario, refusedAuthorizationScenario, heartbeatTimeoutScenario, + meterValueZeroScenario, ] as const; export const scenarioNames = [ @@ -121,6 +124,7 @@ export const scenarioNames = [ 'firmware-update-failure', 'refused-authorization', 'heartbeat-timeout', + 'meter-value-zero', ] as const; export { @@ -143,6 +147,7 @@ export { firmwareUpdateFailureScenario, refusedAuthorizationScenario, heartbeatTimeoutScenario, + meterValueZeroScenario, }; export { compareScenarioReports } from './compare.js'; diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs index ad65c2f..964a886 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 === 19, - `19 scenarios exported (got ${scenarios.scenarios.length})`, + scenarios.scenarios.length === 20, + `20 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');