import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
-import { RegistrationStatusEnumType } from '../../src/types/index.js'
+import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js'
import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
await describe('ChargingStation', async () => {
})
})
+ // ===== B02/B03 BOOT NOTIFICATION BEHAVIOR TESTS =====
+ // These tests verify behavioral requirements, not just state detection (which is tested above)
+ await describe('B02 - Pending Boot Notification Behavior', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // B02.FR.01: Station stores currentTime and interval from Pending response
+ await it('should store interval from Pending response for retry scheduling', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+
+ // Assert - Pending response should have interval for retry
+ expect(station.bootNotificationResponse).toBeDefined()
+ expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
+ expect(station.inPendingState()).toBe(true)
+ })
+
+ // B02.FR.02: Station should be able to transition out of Pending via new response
+ await it('should transition from Pending to Accepted when receiving new response', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - Simulate receiving Accepted response (as would happen after retry)
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 300,
+ status: RegistrationStatusEnumType.ACCEPTED,
+ }
+
+ // Assert - Should now be in Accepted state
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+
+ // B02.FR.03: Pending station should have valid heartbeat interval for operation
+ await it('should use interval from response as heartbeat interval when Pending', () => {
+ // Arrange - Create station with specific interval
+ const customInterval = 120 // seconds
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ heartbeatInterval: customInterval,
+ })
+ station = result.station
+
+ // Assert - Heartbeat interval should match response interval
+ expect(station.getHeartbeatInterval()).toBe(customInterval * 1000)
+ expect(station.inPendingState()).toBe(true)
+ })
+
+ // B02.FR.06: Station should handle clock synchronization from response
+ await it('should have currentTime in Pending response for clock sync', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+
+ // Assert - currentTime should be present for clock synchronization
+ expect(station.bootNotificationResponse?.currentTime).toBeDefined()
+ expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
+ })
+
+ // B02.FR.04/05: Station should be able to transition to Rejected from Pending
+ await it('should transition from Pending to Rejected when receiving rejection', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ })
+ station = result.station
+ expect(station.inPendingState()).toBe(true)
+
+ // Act - Simulate receiving Rejected response
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 3600, // Longer interval for rejected state
+ status: RegistrationStatusEnumType.REJECTED,
+ }
+
+ // Assert - Should now be in Rejected state
+ expect(station.inRejectedState()).toBe(true)
+ expect(station.inPendingState()).toBe(false)
+ })
+ })
+
+ await describe('B03 - Rejected Boot Notification Behavior', async () => {
+ let station: ChargingStation | undefined
+
+ afterEach(() => {
+ if (station != null) {
+ cleanupChargingStation(station)
+ }
+ })
+
+ // B03.FR.01: Station stores currentTime and interval from Rejected response
+ await it('should store interval from Rejected response for retry scheduling', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+
+ // Assert - Rejected response should have interval for retry (typically longer)
+ expect(station.bootNotificationResponse).toBeDefined()
+ expect(station.bootNotificationResponse?.interval).toBeGreaterThan(0)
+ expect(station.inRejectedState()).toBe(true)
+ })
+
+ // B03.FR.03: Station should NOT initiate non-boot messages when Rejected
+ await it('should not initiate messages when in Rejected state (B03.FR.03)', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ connectorsCount: 2,
+ })
+ station = result.station
+ const mocks = result.mocks
+
+ // Clear any setup messages
+ mocks.webSocket.clearMessages()
+
+ // Assert - Station is in rejected state
+ expect(station.inRejectedState()).toBe(true)
+
+ // Assert - No messages should have been sent (station should be silent)
+ // Per B03.FR.03: CS SHALL NOT send any OCPP message until interval expires
+ expect(mocks.webSocket.sentMessages.length).toBe(0)
+ })
+
+ // B03.FR.04: Station should transition from Rejected to Accepted
+ await it('should transition from Rejected to Accepted when receiving acceptance', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+ expect(station.inRejectedState()).toBe(true)
+
+ // Act - Simulate receiving Accepted response after retry
+ station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 60,
+ status: RegistrationStatusEnumType.ACCEPTED,
+ }
+
+ // Assert - Should now be in Accepted state
+ expect(station.inAcceptedState()).toBe(true)
+ expect(station.inRejectedState()).toBe(false)
+ })
+
+ // B03.FR.05: Station should have currentTime for clock synchronization
+ await it('should have currentTime in Rejected response for clock sync', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ })
+ station = result.station
+
+ // Assert - currentTime should be present
+ expect(station.bootNotificationResponse?.currentTime).toBeDefined()
+ expect(station.bootNotificationResponse?.currentTime instanceof Date).toBe(true)
+ })
+
+ // B03.FR.02: Rejected state should use different (typically longer) retry interval
+ await it('should support configurable retry interval for Rejected state', () => {
+ // Arrange - Create two stations: one pending, one rejected
+ const pendingStation = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.PENDING,
+ heartbeatInterval: 60, // Normal interval
+ })
+
+ const rejectedStation = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ heartbeatInterval: 3600, // Longer interval for rejected
+ })
+
+ // Assert - Both should have their respective intervals
+ expect(pendingStation.station.inPendingState()).toBe(true)
+ expect(rejectedStation.station.inRejectedState()).toBe(true)
+ expect(pendingStation.station.getHeartbeatInterval()).toBe(60000)
+ expect(rejectedStation.station.getHeartbeatInterval()).toBe(3600000)
+
+ // Cleanup
+ cleanupChargingStation(pendingStation.station)
+ cleanupChargingStation(rejectedStation.station)
+ })
+
+ // B03.FR.04 + state preservation: Connectors should maintain state during rejection
+ await it('should preserve connector states during Rejected state', () => {
+ // Arrange
+ const result = createMockChargingStation({
+ bootNotificationStatus: RegistrationStatusEnumType.REJECTED,
+ connectorsCount: 2,
+ })
+ station = result.station
+
+ // Set up connector state
+ const connector1 = station.getConnectorStatus(1)
+ if (connector1 != null) {
+ connector1.availability = AvailabilityType.Operative
+ }
+
+ // Assert - Connector state should be preserved even in Rejected state
+ expect(station.getConnectorStatus(1)?.availability).toBe(AvailabilityType.Operative)
+ expect(station.hasConnector(1)).toBe(true)
+ })
+ })
+
await describe('Configuration Persistence', async () => {
let station: ChargingStation | undefined
--- /dev/null
+import { expect } from '@std/expect'
+import { afterEach, describe, it } from 'node:test'
+
+import type { EmptyObject } from '../../../../src/types/index.js'
+
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import {
+ ConnectorStatusEnum,
+ OCPP20TransactionEventEnumType,
+ OCPP20TriggerReasonEnumType,
+ OCPPVersion,
+} from '../../../../src/types/index.js'
+import {
+ OCPP20ChargingStateEnumType,
+ type OCPP20TransactionContext,
+} from '../../../../src/types/ocpp/2.0/Transaction.js'
+import { Constants, generateUUID } from '../../../../src/utils/index.js'
+import { createChargingStation } from '../../../ChargingStationFactory.js'
+import { TEST_CHARGING_STATION_BASE_NAME } from './OCPP20TestConstants.js'
+import { resetConnectorTransactionState, resetLimits } from './OCPP20TestUtils.js'
+
+/**
+ * E02 - Cable-First Transaction Flow Tests
+ *
+ * Tests for the Cable-First (Plug-in First) transaction pattern where:
+ * 1. User plugs in the cable (CablePluggedIn)
+ * 2. EV is detected (EVDetected)
+ * 3. Authorization occurs (Authorized or implicit)
+ * 4. Charging starts (ChargingStateChanged)
+ * 5. Charging ends (StopAuthorized or EVDeparted)
+ *
+ * These tests verify the full transaction lifecycle, not just trigger reason selection
+ * (which is tested in OCPP20ServiceUtils-TransactionEvent.test.ts).
+ *
+ * FR References:
+ * - E02.FR.01: Cable plug event triggers transaction start consideration
+ * - E02.FR.02: EVDetected indicates vehicle presence for charging readiness
+ * - E02.FR.03: Connector status transitions reflect cable state changes
+ */
+await describe('E02 - Cable-First Transaction Flow', async () => {
+ const mockChargingStation = createChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+ ocppRequestService: {
+ requestHandler: async () => {
+ return Promise.resolve({} as EmptyObject)
+ },
+ },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
+ })
+
+ // Reset limits and state before tests
+ resetLimits(mockChargingStation)
+
+ afterEach(() => {
+ resetConnectorTransactionState(mockChargingStation)
+ })
+
+ // =========================================================================
+ // E02.FR.01: Cable Plug Event Flow Tests
+ // =========================================================================
+ await describe('Cable Plug Event Sequencing', async () => {
+ await it('Should generate CablePluggedIn event as first event in cable-first flow', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Reset sequence number for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Build the cable plug event (first event in cable-first flow)
+ const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ )
+
+ // Assert: First event should have seqNo 0
+ expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(cablePluggedEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(cablePluggedEvent.seqNo).toBe(0)
+ expect(cablePluggedEvent.transactionInfo.transactionId).toBe(transactionId)
+ })
+
+ await it('Should sequence CablePluggedIn → EVDetected → Charging correctly', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Reset sequence for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Step 1: Cable plugged in (Started)
+ const cablePluggedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ )
+
+ // Step 2: EV detected (Updated)
+ const evDetectedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ connectorId,
+ transactionId
+ )
+
+ // Step 3: Charging starts (Updated with ChargingStateChanged)
+ const chargingStartedEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ )
+
+ // Assert sequence numbers follow correct order
+ expect(cablePluggedEvent.seqNo).toBe(0)
+ expect(evDetectedEvent.seqNo).toBe(1)
+ expect(chargingStartedEvent.seqNo).toBe(2)
+
+ // Assert all events share the same transaction ID
+ expect(cablePluggedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(evDetectedEvent.transactionInfo.transactionId).toBe(transactionId)
+ expect(chargingStartedEvent.transactionInfo.transactionId).toBe(transactionId)
+
+ // Assert event types match expected pattern
+ expect(cablePluggedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(evDetectedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(chargingStartedEvent.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ })
+
+ await it('Should handle EVDeparted for cable removal ending transaction', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
+
+ // Reset and setup transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Start transaction with cable plug
+ const startEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ )
+
+ // End transaction with EV departure (cable removal)
+ const endEvent = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ connectorId,
+ transactionId
+ )
+
+ // Assert proper sequencing for cable-initiated start and end
+ expect(startEvent.seqNo).toBe(0)
+ expect(startEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(endEvent.seqNo).toBe(1)
+ expect(endEvent.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ expect(endEvent.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+ })
+ })
+
+ // =========================================================================
+ // E02.FR.02: EV Detection Flow Tests
+ // =========================================================================
+ await describe('EV Detection Flow', async () => {
+ await it('Should include EVDetected between cable plug and charging start', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Build full cable-first flow
+ const events = [
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ ),
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ ]
+
+ // Assert EVDetected comes after CablePluggedIn and before authorization
+ expect(events[0].triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(events[1].triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ expect(events[2].triggerReason).toBe(OCPP20TriggerReasonEnumType.Authorized)
+ expect(events[3].triggerReason).toBe(OCPP20TriggerReasonEnumType.ChargingStateChanged)
+
+ // Assert continuous sequence numbers
+ for (let i = 0; i < events.length; i++) {
+ expect(events[i].seqNo).toBe(i)
+ }
+ })
+ })
+
+ // =========================================================================
+ // E02.FR.03: Connector Status Transitions
+ // =========================================================================
+ await describe('Connector Status Transitions', async () => {
+ await it('Should track connector status through cable-first lifecycle', () => {
+ const connectorId = 1
+
+ // Get connector status object
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connectorStatus).toBeDefined()
+ if (connectorStatus == null) {
+ throw new Error('Connector status should be defined')
+ }
+
+ // Initial state: Available
+ connectorStatus.status = ConnectorStatusEnum.Available
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
+
+ // After cable plug: Preparing (implied by transaction start)
+ connectorStatus.status = ConnectorStatusEnum.Preparing
+ connectorStatus.transactionStarted = true
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Preparing)
+ expect(connectorStatus.transactionStarted).toBe(true)
+
+ // After EV detected and auth: Charging
+ connectorStatus.status = ConnectorStatusEnum.Charging
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Charging)
+
+ // After EV departed: Available again
+ connectorStatus.status = ConnectorStatusEnum.Available
+ connectorStatus.transactionStarted = false
+ expect(connectorStatus.status).toBe(ConnectorStatusEnum.Available)
+ expect(connectorStatus.transactionStarted).toBe(false)
+ })
+
+ await it('Should preserve transaction ID through cable-first flow states', () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
+
+ const connectorStatus = mockChargingStation.getConnectorStatus(connectorId)
+ expect(connectorStatus).toBeDefined()
+ if (connectorStatus == null) {
+ throw new Error('Connector status should be defined')
+ }
+
+ // Set transaction ID at start
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.status = ConnectorStatusEnum.Preparing
+
+ // Transition to charging
+ connectorStatus.status = ConnectorStatusEnum.Charging
+
+ // Transaction ID should persist through state changes
+ expect(connectorStatus.transactionId).toBe(transactionId)
+ expect(connectorStatus.transactionStarted).toBe(true)
+
+ // Transition to finished
+ connectorStatus.status = ConnectorStatusEnum.Finishing
+
+ // Still same transaction until fully ended
+ expect(connectorStatus.transactionId).toBe(transactionId)
+ })
+ })
+
+ // =========================================================================
+ // Full E02 Transaction Lifecycle Tests
+ // =========================================================================
+ await describe('Full Cable-First Transaction Lifecycle', async () => {
+ await it('Should support complete cable-first → charging → cable-removal flow', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Build complete cable-first transaction lifecycle
+ const lifecycle = {
+ cablePlugged: OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ charging: OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ evDeparted: OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ connectorId,
+ transactionId
+ ),
+ }
+
+ // Validate lifecycle event sequence
+ expect(lifecycle.cablePlugged.eventType).toBe(OCPP20TransactionEventEnumType.Started)
+ expect(lifecycle.charging.eventType).toBe(OCPP20TransactionEventEnumType.Updated)
+ expect(lifecycle.evDeparted.eventType).toBe(OCPP20TransactionEventEnumType.Ended)
+
+ // Validate sequence numbers
+ expect(lifecycle.cablePlugged.seqNo).toBe(0)
+ expect(lifecycle.charging.seqNo).toBe(1)
+ expect(lifecycle.evDeparted.seqNo).toBe(2)
+
+ // Validate trigger reasons match cable-first pattern
+ expect(lifecycle.cablePlugged.triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ expect(lifecycle.charging.triggerReason).toBe(
+ OCPP20TriggerReasonEnumType.ChargingStateChanged
+ )
+ expect(lifecycle.evDeparted.triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+
+ // All events should share same transaction ID
+ expect(lifecycle.cablePlugged.transactionInfo.transactionId).toBe(transactionId)
+ expect(lifecycle.charging.transactionInfo.transactionId).toBe(transactionId)
+ expect(lifecycle.evDeparted.transactionInfo.transactionId).toBe(transactionId)
+ })
+
+ await it('Should handle suspended charging states in cable-first flow', () => {
+ const connectorId = 3
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, connectorId)
+
+ // Cable-first flow with suspended state
+ const events = [
+ // 1. Cable plugged
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ connectorId,
+ transactionId
+ ),
+ // 2. Start charging
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ // 3. Suspended by EV
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.SuspendedEV }
+ ),
+ // 4. Resume charging
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.ChargingStateChanged,
+ connectorId,
+ transactionId,
+ { chargingState: OCPP20ChargingStateEnumType.Charging }
+ ),
+ // 5. EV departed
+ OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVDeparted,
+ connectorId,
+ transactionId
+ ),
+ ]
+
+ // Verify sequence numbers are continuous through suspend/resume
+ for (let i = 0; i < events.length; i++) {
+ expect(events[i].seqNo).toBe(i)
+ }
+
+ // Verify all share same transaction ID
+ for (const event of events) {
+ expect(event.transactionInfo.transactionId).toBe(transactionId)
+ }
+ })
+ })
+
+ // =========================================================================
+ // Context-Based Trigger Reason Selection for Cable Events
+ // =========================================================================
+ await describe('Context-Based Cable Event Trigger Selection', async () => {
+ await it('Should select CablePluggedIn from cable_action context with plugged_in state', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'plugged_in',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Started,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.CablePluggedIn)
+ })
+
+ await it('Should select EVDetected from cable_action context with detected state', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'detected',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Updated,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDetected)
+ })
+
+ await it('Should select EVDeparted from cable_action context with unplugged state', () => {
+ const context: OCPP20TransactionContext = {
+ cableState: 'unplugged',
+ source: 'cable_action',
+ }
+
+ const triggerReason = OCPP20ServiceUtils.selectTriggerReason(
+ OCPP20TransactionEventEnumType.Ended,
+ context
+ )
+
+ expect(triggerReason).toBe(OCPP20TriggerReasonEnumType.EVDeparted)
+ })
+ })
+
+ // =========================================================================
+ // Multiple Connector Independence Tests
+ // =========================================================================
+ await describe('Multiple Connector Independence', async () => {
+ await it('Should maintain independent transaction sequences on different connectors', () => {
+ const transactionId1 = generateUUID()
+ const transactionId2 = generateUUID()
+
+ // Reset both connectors
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockChargingStation, 2)
+
+ // Start transaction on connector 1
+ const conn1Start = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ 1,
+ transactionId1
+ )
+
+ // Start transaction on connector 2
+ const conn2Start = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.CablePluggedIn,
+ 2,
+ transactionId2
+ )
+
+ // Update connector 1
+ const conn1Update = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ 1,
+ transactionId1
+ )
+
+ // Update connector 2
+ const conn2Update = OCPP20ServiceUtils.buildTransactionEvent(
+ mockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.EVDetected,
+ 2,
+ transactionId2
+ )
+
+ // Each connector should have independent sequence numbers
+ expect(conn1Start.seqNo).toBe(0)
+ expect(conn2Start.seqNo).toBe(0)
+ expect(conn1Update.seqNo).toBe(1)
+ expect(conn2Update.seqNo).toBe(1)
+
+ // Different transaction IDs
+ expect(conn1Start.transactionInfo.transactionId).toBe(transactionId1)
+ expect(conn2Start.transactionInfo.transactionId).toBe(transactionId2)
+ expect(conn1Start.transactionInfo.transactionId).not.toBe(
+ conn2Start.transactionInfo.transactionId
+ )
+ })
+ })
+})