diff --git a/packages/toolkit/src/cli/commands/scenario.ts b/packages/toolkit/src/cli/commands/scenario.ts index 7de9cd1..e8f15f7 100644 --- a/packages/toolkit/src/cli/commands/scenario.ts +++ b/packages/toolkit/src/cli/commands/scenario.ts @@ -1,14 +1,15 @@ /** * `ocpp-debugkit scenario list` and `ocpp-debugkit scenario run ` commands. * - * `scenario run` runs built-in static fixtures through the local analysis - * engine only — it is NOT active endpoint testing, WebSocket simulation, - * live station/CSMS testing, or the v0.2 scenario evaluator. + * `scenario run` runs built-in or external static fixtures through the local + * analysis engine. It is NOT active endpoint testing, WebSocket simulation, + * live station/CSMS testing, or an active scenario runner. */ import { parseTrace, buildSessionTimeline, detectFailures, ParseError } from '../../core/index.js'; +import type { Scenario } from '../../core/index.js'; import { scenarioNames, getScenario } from '../../scenarios/index.js'; -import { CliError } from '../utils.js'; +import { CliError, readTraceFile } from '../utils.js'; export function scenarioListCommand(): void { console.log(''); @@ -32,6 +33,7 @@ export function scenarioListCommand(): void { } console.log('Run with: ocpp-debugkit scenario run '); + console.log(' ocpp-debugkit scenario run --file '); console.log(''); } @@ -44,6 +46,48 @@ export async function scenarioRunCommand(name: string): Promise { ); } + await runScenarioInternal(scenario); +} + +/** + * `ocpp-debugkit scenario run --file ` — run an external scenario file + * through the analysis engine. + * + * The external file must be a JSON object with: name, description, trace, and + * expectedFailures fields (same shape as built-in scenarios). + */ +export async function scenarioRunFileCommand(filePath: string): Promise { + const content = readTraceFile(filePath); + + let scenario: Scenario; + try { + const parsed = JSON.parse(content) as unknown; + if ( + typeof parsed !== 'object' || + parsed === null || + !('trace' in parsed) || + !('name' in parsed) + ) { + throw new CliError('Invalid scenario file: must contain "name" and "trace" fields.'); + } + scenario = parsed as Scenario; + } catch (e) { + if (e instanceof CliError) throw e; + throw new CliError('Invalid JSON in scenario file.'); + } + + if (!scenario.expectedFailures) { + scenario.expectedFailures = []; + } + + await runScenarioInternal(scenario); +} + +// --------------------------------------------------------------------------- +// Shared scenario run logic +// --------------------------------------------------------------------------- + +async function runScenarioInternal(scenario: Scenario): Promise { console.log(''); console.log('═'.repeat(60)); console.log(` Running Scenario: ${scenario.name}`); @@ -71,7 +115,7 @@ export async function scenarioRunCommand(name: string): Promise { // Compare detected vs expected failures const detectedCodes = new Set(failures.map((f) => f.code)); - const expectedCodes = new Set(scenario.expectedFailures); + const expectedCodes = new Set(scenario.expectedFailures as string[]); const correctlyDetected = [...expectedCodes].filter((c) => detectedCodes.has(c)); const unexpectedFailures = [...detectedCodes].filter((c) => !expectedCodes.has(c)); diff --git a/packages/toolkit/src/cli/index.ts b/packages/toolkit/src/cli/index.ts index b6aec3a..6ee3dbe 100644 --- a/packages/toolkit/src/cli/index.ts +++ b/packages/toolkit/src/cli/index.ts @@ -7,7 +7,11 @@ import { Command } from 'commander'; import { inspectCommand } from './commands/inspect.js'; import { reportCommand } from './commands/report.js'; -import { scenarioListCommand, scenarioRunCommand } from './commands/scenario.js'; +import { + scenarioListCommand, + scenarioRunCommand, + scenarioRunFileCommand, +} from './commands/scenario.js'; const program = new Command(); @@ -46,10 +50,17 @@ scenarioCmd }); scenarioCmd - .command('run ') - .description('Run a scenario through the analysis engine') - .action(async (name: string) => { - await scenarioRunCommand(name); + .command('run [name]') + .description('Run a scenario through the analysis engine (built-in or --file )') + .option('-f, --file ', 'Run an external scenario file instead of a built-in scenario') + .action(async (name: string | undefined, options: { file?: string }) => { + if (options.file) { + await scenarioRunFileCommand(options.file); + } else if (name) { + await scenarioRunCommand(name); + } else { + throw new Error('Either a scenario name or --file is required.'); + } }); program.parse(); diff --git a/packages/toolkit/src/scenarios/__scenarios__/diagnostics-failure.ts b/packages/toolkit/src/scenarios/__scenarios__/diagnostics-failure.ts new file mode 100644 index 0000000..6772dc2 --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/diagnostics-failure.ts @@ -0,0 +1,92 @@ +export default { + name: 'diagnostics-failure', + description: + 'DiagnosticsStatusNotification with DiagnosisFailed status indicating a hardware diagnostics failure. Expects DIAGNOSTICS_FAILURE failure.', + trace: { + traceId: 'scenario-diagnostics-failure', + metadata: { + stationId: 'CS-SYNTHETIC-010', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'Station reports a diagnostics failure via DiagnosticsStatusNotification with status DiagnosisFailed.', + }, + events: [ + { + timestamp: '2024-01-15T06:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-010', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2024-01-15T06:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { + currentTime: '2024-01-15T06:00:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2024-01-15T06:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-002', + 'StatusNotification', + { + connectorId: 0, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T06:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', {}], + }, + { + timestamp: '2024-01-15T06:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T06:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T06:07:30.500Z' }], + }, + { + timestamp: '2024-01-15T06:12:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'DiagnosticsStatusNotification', + { + status: 'DiagnosisFailed', + }, + ], + }, + { + timestamp: '2024-01-15T06:12:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-003', {}], + }, + ], + }, + expectedFailures: ['DIAGNOSTICS_FAILURE'], +}; diff --git a/packages/toolkit/src/scenarios/__scenarios__/invalid-stop-reason.ts b/packages/toolkit/src/scenarios/__scenarios__/invalid-stop-reason.ts new file mode 100644 index 0000000..b9f5b4b --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/invalid-stop-reason.ts @@ -0,0 +1,227 @@ +export default { + name: 'invalid-stop-reason', + description: + 'StopTransaction with an invalid stop reason not in the OCPP 1.6 specification. Expects INVALID_STOP_REASON failure.', + trace: { + traceId: 'scenario-invalid-stop-reason', + metadata: { + stationId: 'CS-SYNTHETIC-007', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'StopTransaction with reason "PowerSurge" — not a valid OCPP 1.6 stop reason code.', + }, + events: [ + { + timestamp: '2024-01-15T20:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-007', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2024-01-15T20:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { + currentTime: '2024-01-15T20:00:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2024-01-15T20:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-002', + 'StatusNotification', + { + connectorId: 0, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T20:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', {}], + }, + { + timestamp: '2024-01-15T20:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'Authorize', + { + idTag: 'SYNTHETIC-TAG-007', + }, + ], + }, + { + timestamp: '2024-01-15T20:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-003', + { + idTagInfo: { + status: 'Accepted', + expiryDate: '2024-12-31T23:59:59.000Z', + }, + }, + ], + }, + { + timestamp: '2024-01-15T20:02:30.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'StartTransaction', + { + connectorId: 1, + idTag: 'SYNTHETIC-TAG-007', + meterStart: 0, + timestamp: '2024-01-15T20:02:30.000Z', + }, + ], + }, + { + timestamp: '2024-01-15T20:02:30.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-004', + { + transactionId: 100007, + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + { + timestamp: '2024-01-15T20:02:31.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-005', + 'StatusNotification', + { + connectorId: 1, + status: 'Charging', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T20:02:31.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-005', {}], + }, + { + timestamp: '2024-01-15T20:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T20:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T20:07:30.500Z' }], + }, + { + timestamp: '2024-01-15T20:15:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-006', + 'MeterValues', + { + connectorId: 1, + transactionId: 100007, + meterValue: [ + { + timestamp: '2024-01-15T20:15:00.000Z', + sampledValue: [ + { + value: '5000', + measurand: 'Energy.Active.Import.Register', + unit: 'Wh', + }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2024-01-15T20:15:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-006', {}], + }, + { + timestamp: '2024-01-15T20:30:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-007', + 'StopTransaction', + { + transactionId: 100007, + idTag: 'SYNTHETIC-TAG-007', + meterStop: 5000, + timestamp: '2024-01-15T20:30:00.000Z', + reason: 'PowerSurge', + }, + ], + }, + { + timestamp: '2024-01-15T20:30:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-007', + { + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + { + timestamp: '2024-01-15T20:30:01.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-008', + 'StatusNotification', + { + connectorId: 1, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T20:30:01.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-008', {}], + }, + ], + }, + expectedFailures: ['INVALID_STOP_REASON'], +}; diff --git a/packages/toolkit/src/scenarios/__scenarios__/meter-value-gap.ts b/packages/toolkit/src/scenarios/__scenarios__/meter-value-gap.ts new file mode 100644 index 0000000..db5ded3 --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/meter-value-gap.ts @@ -0,0 +1,196 @@ +export default { + name: 'meter-value-gap', + description: + 'Active transaction with StartTransaction and StopTransaction but no MeterValues in between. Expects METER_VALUE_GAP failure.', + trace: { + traceId: 'scenario-meter-value-gap', + metadata: { + stationId: 'CS-SYNTHETIC-006', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: 'Transaction completes without any MeterValues — metering data is missing.', + }, + events: [ + { + timestamp: '2024-01-15T18:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-006', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2024-01-15T18:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { + currentTime: '2024-01-15T18:00:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2024-01-15T18:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-002', + 'StatusNotification', + { + connectorId: 0, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T18:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', {}], + }, + { + timestamp: '2024-01-15T18:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'Authorize', + { + idTag: 'SYNTHETIC-TAG-006', + }, + ], + }, + { + timestamp: '2024-01-15T18:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-003', + { + idTagInfo: { + status: 'Accepted', + expiryDate: '2024-12-31T23:59:59.000Z', + }, + }, + ], + }, + { + timestamp: '2024-01-15T18:02:30.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'StartTransaction', + { + connectorId: 1, + idTag: 'SYNTHETIC-TAG-006', + meterStart: 0, + timestamp: '2024-01-15T18:02:30.000Z', + }, + ], + }, + { + timestamp: '2024-01-15T18:02:30.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-004', + { + transactionId: 100006, + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + { + timestamp: '2024-01-15T18:02:31.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-005', + 'StatusNotification', + { + connectorId: 1, + status: 'Charging', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T18:02:31.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-005', {}], + }, + { + timestamp: '2024-01-15T18:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T18:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T18:07:30.500Z' }], + }, + { + timestamp: '2024-01-15T18:30:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-006', + 'StopTransaction', + { + transactionId: 100006, + idTag: 'SYNTHETIC-TAG-006', + meterStop: 0, + timestamp: '2024-01-15T18:30:00.000Z', + reason: 'EVDisconnected', + }, + ], + }, + { + timestamp: '2024-01-15T18:30:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-006', + { + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + { + timestamp: '2024-01-15T18:30:01.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-007', + 'StatusNotification', + { + connectorId: 1, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T18:30:01.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-007', {}], + }, + ], + }, + expectedFailures: ['METER_VALUE_GAP'], +}; diff --git a/packages/toolkit/src/scenarios/__scenarios__/status-transition-violation.ts b/packages/toolkit/src/scenarios/__scenarios__/status-transition-violation.ts new file mode 100644 index 0000000..5528ef6 --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/status-transition-violation.ts @@ -0,0 +1,113 @@ +export default { + name: 'status-transition-violation', + description: + 'Connector status transitions from Available directly to Finishing, which is not a valid OCPP 1.6 transition. Expects STATUS_TRANSITION_VIOLATION failure.', + trace: { + traceId: 'scenario-status-transition-violation', + metadata: { + stationId: 'CS-SYNTHETIC-009', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'Connector status jumps from Available to Finishing without going through Preparing/Charging — invalid OCPP 1.6 transition.', + }, + events: [ + { + timestamp: '2024-01-15T08:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'BootNotification', + { + chargePointVendor: 'SyntheticVendor', + chargePointModel: 'SM-100', + chargePointSerialNumber: 'CS-SYNTHETIC-009', + firmwareVersion: '1.0.0', + }, + ], + }, + { + timestamp: '2024-01-15T08:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { + currentTime: '2024-01-15T08:00:00.500Z', + interval: 300, + status: 'Accepted', + }, + ], + }, + { + timestamp: '2024-01-15T08:01:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-002', + 'StatusNotification', + { + connectorId: 0, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T08:01:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', {}], + }, + { + timestamp: '2024-01-15T08:02:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'StatusNotification', + { + connectorId: 1, + status: 'Available', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T08:02:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-003', {}], + }, + { + timestamp: '2024-01-15T08:07:30.000Z', + direction: 'CS_TO_CSMS', + message: [2, 'msg-hb-1', 'Heartbeat', {}], + }, + { + timestamp: '2024-01-15T08:07:30.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-hb-1', { currentTime: '2024-01-15T08:07:30.500Z' }], + }, + { + timestamp: '2024-01-15T08:12:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'StatusNotification', + { + connectorId: 1, + status: 'Finishing', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T08:12:00.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-004', {}], + }, + ], + }, + expectedFailures: ['STATUS_TRANSITION_VIOLATION'], +}; diff --git a/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts b/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts new file mode 100644 index 0000000..a5ff6ef --- /dev/null +++ b/packages/toolkit/src/scenarios/__scenarios__/unexpected-start.ts @@ -0,0 +1,125 @@ +export default { + name: 'unexpected-start', + description: + 'StartTransaction without preceding BootNotification or Authorize. Expects UNEXPECTED_START failure.', + trace: { + traceId: 'scenario-unexpected-start', + metadata: { + stationId: 'CS-SYNTHETIC-008', + ocppVersion: '1.6', + source: 'synthetic-scenario', + description: + 'Station sends StartTransaction without BootNotification or Authorize — transaction started without proper initialization.', + }, + events: [ + { + timestamp: '2024-01-15T22:00:00.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-001', + 'StartTransaction', + { + connectorId: 1, + idTag: 'SYNTHETIC-TAG-008', + meterStart: 0, + timestamp: '2024-01-15T22:00:00.000Z', + }, + ], + }, + { + timestamp: '2024-01-15T22:00:00.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-001', + { + transactionId: 100008, + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + { + timestamp: '2024-01-15T22:00:01.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-002', + 'StatusNotification', + { + connectorId: 1, + status: 'Charging', + errorCode: 'NoError', + }, + ], + }, + { + timestamp: '2024-01-15T22:00:01.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-002', {}], + }, + { + timestamp: '2024-01-15T22:00:10.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-003', + 'MeterValues', + { + connectorId: 1, + transactionId: 100008, + meterValue: [ + { + timestamp: '2024-01-15T22:00:10.000Z', + sampledValue: [ + { + value: '100', + measurand: 'Energy.Active.Import.Register', + unit: 'Wh', + }, + ], + }, + ], + }, + ], + }, + { + timestamp: '2024-01-15T22:00:10.500Z', + direction: 'CSMS_TO_CS', + message: [3, 'msg-003', {}], + }, + { + timestamp: '2024-01-15T22:00:30.000Z', + direction: 'CS_TO_CSMS', + message: [ + 2, + 'msg-004', + 'StopTransaction', + { + transactionId: 100008, + idTag: 'SYNTHETIC-TAG-008', + meterStop: 100, + timestamp: '2024-01-15T22:00:30.000Z', + reason: 'EVDisconnected', + }, + ], + }, + { + timestamp: '2024-01-15T22:00:30.500Z', + direction: 'CSMS_TO_CS', + message: [ + 3, + 'msg-004', + { + idTagInfo: { + status: 'Accepted', + }, + }, + ], + }, + ], + }, + expectedFailures: ['UNEXPECTED_START'], +}; diff --git a/packages/toolkit/src/scenarios/index.test.ts b/packages/toolkit/src/scenarios/index.test.ts index bb77404..d8531e1 100644 --- a/packages/toolkit/src/scenarios/index.test.ts +++ b/packages/toolkit/src/scenarios/index.test.ts @@ -8,6 +8,11 @@ import { connectorFaultScenario, stationOfflineScenario, unexpectedStopReasonScenario, + meterValueGapScenario, + invalidStopReasonScenario, + unexpectedStartScenario, + statusTransitionViolationScenario, + diagnosticsFailureScenario, } from './index.js'; import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js'; @@ -16,8 +21,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index. // --------------------------------------------------------------------------- describe('scenario registry', () => { - it('exports exactly 5 scenarios', () => { - expect(scenarios).toHaveLength(5); + it('exports exactly 10 scenarios', () => { + expect(scenarios).toHaveLength(10); }); it('exports scenario names in order', () => { @@ -27,6 +32,11 @@ describe('scenario registry', () => { 'connector-fault', 'station-offline', 'unexpected-stop-reason', + 'meter-value-gap', + 'invalid-stop-reason', + 'unexpected-start', + 'status-transition-violation', + 'diagnostics-failure', ]); }); @@ -45,6 +55,11 @@ describe('scenario registry', () => { expect(getScenario('connector-fault')).toBe(connectorFaultScenario); expect(getScenario('station-offline')).toBe(stationOfflineScenario); expect(getScenario('unexpected-stop-reason')).toBe(unexpectedStopReasonScenario); + expect(getScenario('meter-value-gap')).toBe(meterValueGapScenario); + expect(getScenario('invalid-stop-reason')).toBe(invalidStopReasonScenario); + expect(getScenario('unexpected-start')).toBe(unexpectedStartScenario); + expect(getScenario('status-transition-violation')).toBe(statusTransitionViolationScenario); + expect(getScenario('diagnostics-failure')).toBe(diagnosticsFailureScenario); }); it('getScenario returns undefined for unknown name', () => { @@ -53,17 +68,24 @@ describe('scenario registry', () => { }); // --------------------------------------------------------------------------- -// Expected failures alignment (pitfall: scenarios must match v0.1 detection rules) +// Expected failures alignment with detection rules // --------------------------------------------------------------------------- -describe('expectedFailures alignment with v0.1 detection rules', () => { +describe('expectedFailures alignment with detection rules', () => { const VALID_FAILURE_CODES = new Set([ 'FAILED_AUTHORIZATION', 'CONNECTOR_FAULT', 'STATION_OFFLINE_DURING_SESSION', + 'TIMEOUT_NO_HEARTBEAT', + 'METER_VALUE_GAP', + 'INVALID_STOP_REASON', + 'UNEXPECTED_START', + 'STATUS_TRANSITION_VIOLATION', + 'DIAGNOSTICS_FAILURE', + 'FIRMWARE_UPDATE_FAILURE', ]); - it('all expectedFailures reference valid v0.1 failure codes', () => { + it('all expectedFailures reference valid failure codes', () => { for (const scenario of scenarios) { for (const code of scenario.expectedFailures) { expect(VALID_FAILURE_CODES.has(code)).toBe(true); @@ -116,6 +138,46 @@ describe('scenario engine integration', () => { const failures = detectFailures(result.events, sessions); expect(failures).toHaveLength(0); }); + + it('meter-value-gap: detects METER_VALUE_GAP', () => { + const trace = JSON.stringify(meterValueGapScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'METER_VALUE_GAP')).toBe(true); + }); + + it('invalid-stop-reason: detects INVALID_STOP_REASON', () => { + const trace = JSON.stringify(invalidStopReasonScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'INVALID_STOP_REASON')).toBe(true); + }); + + it('unexpected-start: detects UNEXPECTED_START', () => { + const trace = JSON.stringify(unexpectedStartScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'UNEXPECTED_START')).toBe(true); + }); + + it('status-transition-violation: detects STATUS_TRANSITION_VIOLATION', () => { + const trace = JSON.stringify(statusTransitionViolationScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'STATUS_TRANSITION_VIOLATION')).toBe(true); + }); + + it('diagnostics-failure: detects DIAGNOSTICS_FAILURE', () => { + const trace = JSON.stringify(diagnosticsFailureScenario.trace); + const result = parseTrace(trace); + const sessions = buildSessionTimeline(result.events); + const failures = detectFailures(result.events, sessions); + expect(failures.some((f) => f.code === 'DIAGNOSTICS_FAILURE')).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/toolkit/src/scenarios/index.ts b/packages/toolkit/src/scenarios/index.ts index 7bc1524..3cd18bb 100644 --- a/packages/toolkit/src/scenarios/index.ts +++ b/packages/toolkit/src/scenarios/index.ts @@ -10,6 +10,11 @@ import { fixtures } from '../core/index.js'; import stationOffline from './__scenarios__/station-offline.js'; import unexpectedStopReason from './__scenarios__/unexpected-stop-reason.js'; +import meterValueGap from './__scenarios__/meter-value-gap.js'; +import invalidStopReason from './__scenarios__/invalid-stop-reason.js'; +import unexpectedStart from './__scenarios__/unexpected-start.js'; +import statusTransitionViolation from './__scenarios__/status-transition-violation.js'; +import diagnosticsFailure from './__scenarios__/diagnostics-failure.js'; // --------------------------------------------------------------------------- // Scenarios derived from core fixtures @@ -18,7 +23,7 @@ import unexpectedStopReason from './__scenarios__/unexpected-stop-reason.js'; const normalSessionScenario: Scenario = { name: 'normal-session', description: - 'Normal charging session: boot → authorize → start transaction → meter values → stop transaction. No failures expected.', + 'Normal charging session: boot, authorize, start transaction, meter values, stop transaction. No failures expected.', trace: fixtures.normalSession as Trace, expectedFailures: [], }; @@ -26,7 +31,7 @@ const normalSessionScenario: Scenario = { const failedAuthScenario: Scenario = { name: 'failed-auth', description: - 'Failed authorization: station connector prepares, idTag is rejected by CSMS. StartTransaction is not attempted. Expects FAILED_AUTHORIZATION failure.', + 'Failed authorization: station prepares, idTag is rejected by CSMS. StartTransaction is not attempted. Expects FAILED_AUTHORIZATION failure.', trace: fixtures.failedAuth as Trace, expectedFailures: ['FAILED_AUTHORIZATION'], }; @@ -40,11 +45,17 @@ const connectorFaultScenario: Scenario = { }; // --------------------------------------------------------------------------- -// Scenarios from JSON files +// Scenarios from fixture files // --------------------------------------------------------------------------- const stationOfflineScenario: Scenario = stationOffline as unknown as Scenario; const unexpectedStopReasonScenario: Scenario = unexpectedStopReason as unknown as Scenario; +const meterValueGapScenario: Scenario = meterValueGap as unknown as Scenario; +const invalidStopReasonScenario: Scenario = invalidStopReason as unknown as Scenario; +const unexpectedStartScenario: Scenario = unexpectedStart as unknown as Scenario; +const statusTransitionViolationScenario: Scenario = + statusTransitionViolation as unknown as Scenario; +const diagnosticsFailureScenario: Scenario = diagnosticsFailure as unknown as Scenario; // --------------------------------------------------------------------------- // Registry @@ -56,6 +67,11 @@ export const scenarios = [ connectorFaultScenario, stationOfflineScenario, unexpectedStopReasonScenario, + meterValueGapScenario, + invalidStopReasonScenario, + unexpectedStartScenario, + statusTransitionViolationScenario, + diagnosticsFailureScenario, ] as const; export const scenarioNames = [ @@ -64,6 +80,11 @@ export const scenarioNames = [ 'connector-fault', 'station-offline', 'unexpected-stop-reason', + 'meter-value-gap', + 'invalid-stop-reason', + 'unexpected-start', + 'status-transition-violation', + 'diagnostics-failure', ] as const; export { @@ -72,6 +93,11 @@ export { connectorFaultScenario, stationOfflineScenario, unexpectedStopReasonScenario, + meterValueGapScenario, + invalidStopReasonScenario, + unexpectedStartScenario, + statusTransitionViolationScenario, + diagnosticsFailureScenario, }; /** diff --git a/tests/external-fixture/test.mjs b/tests/external-fixture/test.mjs index da60c5f..3c623e7 100644 --- a/tests/external-fixture/test.mjs +++ b/tests/external-fixture/test.mjs @@ -110,8 +110,8 @@ const scenarios = await import('@ocpp-debugkit/toolkit/scenarios'); assert(Array.isArray(scenarios.scenarios), 'scenarios is an array'); assert( - scenarios.scenarios.length === 5, - `5 scenarios exported (got ${scenarios.scenarios.length})`, + scenarios.scenarios.length === 10, + `10 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');