diff --git a/.changeset/repeated-boot-notification-scenario.md b/.changeset/repeated-boot-notification-scenario.md new file mode 100644 index 0000000..ef94c21 --- /dev/null +++ b/.changeset/repeated-boot-notification-scenario.md @@ -0,0 +1,5 @@ +--- +'@ocpp-debugkit/toolkit': patch +--- + +Add `repeated-boot-notification` scenario covering the `REPEATED_BOOT_NOTIFICATION` detection rule. The synthetic trace reboots a station three times in three minutes (one `BootNotification` per minute) followed by a single `Heartbeat`, exercising the 5 minute window used by the rule. diff --git a/README.md b/README.md index c71f59c..cc5949d 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** — 20 predefined scenarios with expected failure +- **Scenario Evaluator** — 21 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 68dfafd..6356453 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** — 20 predefined scenarios with expected failure +- **Scenario Evaluator** — 21 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__/repeated-boot-notification.ts b/packages/toolkit/src/scenarios/__scenarios__/repeated-boot-notification.ts new file mode 100644 index 0000000..3fc43cb --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/repeated-boot-notification.ts @@ -0,0 +1,117 @@ +export default { + name: 'repeated-boot-notification', + description: + 'Station rebooting in a loop: three BootNotification calls one minute apart, followed by a single Heartbeat. All three boots land inside the five minute window. Expects REPEATED_BOOT_NOTIFICATION failure. Uses event_count assertion for BootNotification.', + trace: { + traceId: 'scenario-repeated-boot-notification', + metadata: { + stationId: 'CS-SYNTHETIC-020', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: 'Station reboots three times in three minutes — reboot loop expected.', + }, + events: [ + { + timestamp: '2026-01-15T08:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-boot-1', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-020', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2026-01-15T08:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-boot-1', + { + currentTime: '2026-01-15T08:00:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2026-01-15T08:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-boot-2', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-020', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2026-01-15T08:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-boot-2', + { + currentTime: '2026-01-15T08:01:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2026-01-15T08:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-boot-3', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-020', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2026-01-15T08:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-boot-3', + { + currentTime: '2026-01-15T08:02:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2026-01-15T08:03:00.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2026-01-15T08:03:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2026-01-15T08:03:00.500Z' }], + }, + ], + }, + expectedFailures: ['REPEATED_BOOT_NOTIFICATION'], + assertions: [ + { + type: 'event_count', + params: { action: 'BootNotification', min: 3 }, + }, + ], +}; diff --git a/packages/toolkit/src/scenarios/index.test.ts b/packages/toolkit/src/scenarios/index.test.ts index a04b3a2..7773903 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, + repeatedBootNotificationScenario, meterValueZeroScenario, } from './index.js'; import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js'; @@ -31,8 +32,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index. // --------------------------------------------------------------------------- describe('scenario registry', () => { - it('exports exactly 20 scenarios', () => { - expect(scenarios).toHaveLength(20); + it('exports exactly 21 scenarios', () => { + expect(scenarios).toHaveLength(21); }); it('exports scenario names in order', () => { @@ -56,6 +57,7 @@ describe('scenario registry', () => { 'firmware-update-failure', 'refused-authorization', 'heartbeat-timeout', + 'repeated-boot-notification', 'meter-value-zero', ]); }); @@ -89,6 +91,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('repeated-boot-notification')).toBe(repeatedBootNotificationScenario); expect(getScenario('meter-value-zero')).toBe(meterValueZeroScenario); }); @@ -246,6 +249,14 @@ describe('scenario engine integration', () => { expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true); }); + it('repeated-boot-notification: detects REPEATED_BOOT_NOTIFICATION', () => { + const trace = JSON.stringify(repeatedBootNotificationScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'REPEATED_BOOT_NOTIFICATION')).toBe(true); + }); + it('meter-value-zero: no failures detected', () => { const trace = JSON.stringify(meterValueZeroScenario.trace); const result = parseTrace(trace); diff --git a/packages/toolkit/src/scenarios/index.ts b/packages/toolkit/src/scenarios/index.ts index db6c2a1..fa601d5 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 repeatedBootNotification from './__scenarios__/repeated-boot-notification.js'; import meterValueZero from './__scenarios__/meter-value-zero.js'; // --------------------------------------------------------------------------- @@ -75,6 +76,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 repeatedBootNotificationScenario: Scenario = repeatedBootNotification as unknown as Scenario; const meterValueZeroScenario: Scenario = meterValueZero as unknown as Scenario; // --------------------------------------------------------------------------- @@ -101,6 +103,7 @@ export const scenarios = [ firmwareUpdateFailureScenario, refusedAuthorizationScenario, heartbeatTimeoutScenario, + repeatedBootNotificationScenario, meterValueZeroScenario, ] as const; @@ -124,6 +127,7 @@ export const scenarioNames = [ 'firmware-update-failure', 'refused-authorization', 'heartbeat-timeout', + 'repeated-boot-notification', 'meter-value-zero', ] as const; @@ -147,6 +151,7 @@ export { firmwareUpdateFailureScenario, refusedAuthorizationScenario, heartbeatTimeoutScenario, + repeatedBootNotificationScenario, meterValueZeroScenario, }; diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs index 964a886..4b6586b 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 === 20, - `20 scenarios exported (got ${scenarios.scenarios.length})`, + scenarios.scenarios.length === 21, + `21 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');