Skip to content

Commit dc2a77c

Browse files
authored
feat(scenarios): add heartbeat-timeout scenario for TIMEOUT_NO_HEARTBEAT (#161)
Adds a `heartbeat-timeout` scenario covering `TIMEOUT_NO_HEARTBEAT`, which had no scenario exercising it. The station boots with `interval: 300`, so the rule's threshold lands at twice that, 600s. A single `StatusNotification` at 720s sits past the threshold and gives the rule the trailing event it needs, and no `Heartbeat` appears anywhere in the trace. `expectedFailures` is `['TIMEOUT_NO_HEARTBEAT']`, asserted at `warning` severity and exactly once. Takes the scenario registry to 19, updating the count in the registry test, the external fixture test, and both READMEs. The detection-rule count stays 16, since a scenario is not a rule. Also picks up a missing `getScenario('refused-authorization')` assertion in `index.test.ts`, a gap left behind by #156. Closes #137
1 parent 84ba917 commit dc2a77c

7 files changed

Lines changed: 98 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ocpp-debugkit/toolkit': patch
3+
---
4+
5+
Add `heartbeat-timeout` scenario covering the `TIMEOUT_NO_HEARTBEAT` detection rule. The synthetic trace boots a station with `interval=300`, then sends a `StatusNotification` past the 2× interval threshold (`06:12:00.000Z`) with no `Heartbeat` anywhere in the trace.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ validate behavior against known scenarios.
2222
firmware update failures, suspicious session duration, slow CSMS responses,
2323
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2424
repeated boot notifications.
25-
- **Scenario Evaluator**18 predefined scenarios with expected failure
25+
- **Scenario Evaluator**19 predefined scenarios with expected failure
2626
outcomes for testing the analysis engine. Supports external scenario files.
2727
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2828
jump-to-event, and configurable playback speed.

packages/toolkit/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ report generation, React components, and CLI.
2020
firmware update failures, suspicious session duration, slow CSMS responses,
2121
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2222
repeated boot notifications.
23-
- **Scenario Evaluator**18 predefined scenarios with expected failure
23+
- **Scenario Evaluator**19 predefined scenarios with expected failure
2424
outcomes for testing the analysis engine. Supports external scenario files.
2525
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2626
jump-to-event, and configurable playback speed. No timers or I/O.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
export default {
2+
name: 'heartbeat-timeout',
3+
description:
4+
'Station boots and never sends a Heartbeat. BootNotification response sets interval=300s, threshold at 600s. StatusNotification after threshold triggers TIMEOUT_NO_HEARTBEAT.',
5+
trace: {
6+
traceId: 'scenario-heartbeat-timeout',
7+
metadata: {
8+
stationId: 'CS-SYNTHETIC-018',
9+
ocppVersion: '1.6',
10+
source: 'synthetic-scenario',
11+
description: 'Station boots and never sends a Heartbeat — timeout expected.',
12+
},
13+
events: [
14+
{
15+
timestamp: '2026-01-15T06:00:00.000Z',
16+
direction: 'CS_TO_CSMS',
17+
message: [
18+
2,
19+
'msg-001',
20+
'BootNotification',
21+
{
22+
chargePointVendor: 'SyntheticVendor',
23+
chargePointModel: 'SM-100',
24+
chargePointSerialNumber: 'CS-SYNTHETIC-018',
25+
firmwareVersion: '1.0.0',
26+
},
27+
],
28+
},
29+
{
30+
timestamp: '2026-01-15T06:00:00.500Z',
31+
direction: 'CSMS_TO_CS',
32+
message: [
33+
3,
34+
'msg-001',
35+
{
36+
currentTime: '2026-01-15T06:00:00.500Z',
37+
interval: 300,
38+
status: 'Accepted',
39+
},
40+
],
41+
},
42+
{
43+
timestamp: '2026-01-15T06:12:00.000Z',
44+
direction: 'CS_TO_CSMS',
45+
message: [
46+
2,
47+
'msg-002',
48+
'StatusNotification',
49+
{ connectorId: 1, status: 'Available', errorCode: 'NoError' },
50+
],
51+
},
52+
{
53+
timestamp: '2026-01-15T06:12:00.500Z',
54+
direction: 'CSMS_TO_CS',
55+
message: [3, 'msg-002', {}],
56+
},
57+
],
58+
},
59+
expectedFailures: ['TIMEOUT_NO_HEARTBEAT'],
60+
assertions: [
61+
{
62+
type: 'failure_severity',
63+
params: { code: 'TIMEOUT_NO_HEARTBEAT', severity: 'warning' },
64+
},
65+
{
66+
type: 'failure_count',
67+
params: { code: 'TIMEOUT_NO_HEARTBEAT', min: 1, max: 1 },
68+
},
69+
],
70+
};

packages/toolkit/src/scenarios/index.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
firmwareUpdateSuccessScenario,
2222
firmwareUpdateFailureScenario,
2323
refusedAuthorizationScenario,
24+
heartbeatTimeoutScenario,
2425
} from './index.js';
2526
import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js';
2627

@@ -29,8 +30,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.
2930
// ---------------------------------------------------------------------------
3031

3132
describe('scenario registry', () => {
32-
it('exports exactly 18 scenarios', () => {
33-
expect(scenarios).toHaveLength(18);
33+
it('exports exactly 19 scenarios', () => {
34+
expect(scenarios).toHaveLength(19);
3435
});
3536

3637
it('exports scenario names in order', () => {
@@ -53,6 +54,7 @@ describe('scenario registry', () => {
5354
'firmware-update-success',
5455
'firmware-update-failure',
5556
'refused-authorization',
57+
'heartbeat-timeout',
5658
]);
5759
});
5860

@@ -83,6 +85,8 @@ describe('scenario registry', () => {
8385
expect(getScenario('unresponsive-csms')).toBe(unresponsiveCsmsScenario);
8486
expect(getScenario('firmware-update-success')).toBe(firmwareUpdateSuccessScenario);
8587
expect(getScenario('firmware-update-failure')).toBe(firmwareUpdateFailureScenario);
88+
expect(getScenario('refused-authorization')).toBe(refusedAuthorizationScenario);
89+
expect(getScenario('heartbeat-timeout')).toBe(heartbeatTimeoutScenario);
8690
});
8791

8892
it('getScenario returns undefined for unknown name', () => {
@@ -230,6 +234,14 @@ describe('scenario engine integration', () => {
230234
'ConcurrentTx',
231235
]);
232236
});
237+
238+
it('heartbeat-timeout: detects TIMEOUT_NO_HEARTBEAT', () => {
239+
const trace = JSON.stringify(heartbeatTimeoutScenario.trace);
240+
const result = parseTrace(trace);
241+
const sessions = buildSessionTimeline(result.events);
242+
const failures = detectFailures(result.events, sessions);
243+
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true);
244+
});
233245
});
234246

235247
// ---------------------------------------------------------------------------

packages/toolkit/src/scenarios/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import unresponsiveCsms from './__scenarios__/unresponsive-csms.js';
2323
import firmwareUpdateSuccess from './__scenarios__/firmware-update-success.js';
2424
import firmwareUpdateFailure from './__scenarios__/firmware-update-failure.js';
2525
import refusedAuthorization from './__scenarios__/refused-authorization.js';
26+
import heartbeatTimeout from './__scenarios__/heartbeat-timeout.js';
2627

2728
// ---------------------------------------------------------------------------
2829
// Scenarios derived from core fixtures
@@ -72,6 +73,7 @@ const unresponsiveCsmsScenario: Scenario = unresponsiveCsms as unknown as Scenar
7273
const firmwareUpdateSuccessScenario: Scenario = firmwareUpdateSuccess as unknown as Scenario;
7374
const firmwareUpdateFailureScenario: Scenario = firmwareUpdateFailure as unknown as Scenario;
7475
const refusedAuthorizationScenario: Scenario = refusedAuthorization as unknown as Scenario;
76+
const heartbeatTimeoutScenario: Scenario = heartbeatTimeout as unknown as Scenario;
7577

7678
// ---------------------------------------------------------------------------
7779
// Registry
@@ -96,6 +98,7 @@ export const scenarios = [
9698
firmwareUpdateSuccessScenario,
9799
firmwareUpdateFailureScenario,
98100
refusedAuthorizationScenario,
101+
heartbeatTimeoutScenario,
99102
] as const;
100103

101104
export const scenarioNames = [
@@ -117,6 +120,7 @@ export const scenarioNames = [
117120
'firmware-update-success',
118121
'firmware-update-failure',
119122
'refused-authorization',
123+
'heartbeat-timeout',
120124
] as const;
121125

122126
export {
@@ -138,6 +142,7 @@ export {
138142
firmwareUpdateSuccessScenario,
139143
firmwareUpdateFailureScenario,
140144
refusedAuthorizationScenario,
145+
heartbeatTimeoutScenario,
141146
};
142147

143148
export { compareScenarioReports } from './compare.js';

tests/external-fixture/test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ const scenarios = await import('@ocpp-debugkit/toolkit/scenarios');
158158

159159
assert(Array.isArray(scenarios.scenarios), 'scenarios is an array');
160160
assert(
161-
scenarios.scenarios.length === 18,
162-
`18 scenarios exported (got ${scenarios.scenarios.length})`,
161+
scenarios.scenarios.length === 19,
162+
`19 scenarios exported (got ${scenarios.scenarios.length})`,
163163
);
164164
assert(typeof scenarios.getScenario === 'function', 'getScenario is a function');
165165
assert(scenarios.getScenario('normal-session') !== undefined, 'normal-session scenario exists');

0 commit comments

Comments
 (0)