From 2cc95879ce5519b7a1e1df12c43e3bc713420939 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 14 Aug 2026 15:39:35 +0200 Subject: [PATCH] fix(simulator): seed autoRegister and ocppProtocol defaults into stationInfo (#2082) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(simulator): seed autoRegister and ocppProtocol defaults into stationInfo autoRegister and ocppProtocol had no default, so a template omitting them left stationInfo carrying undefined for both. Raw consumers of stationInfo — the UI data payload (buildChargingStationDataPayload) and the persisted configuration — therefore received undefined, so the Web UI station details showed an empty placeholder for Auto Register and OCPP Protocol. Both are static defaults (no derivation), so seed them in DEFAULT_STATION_INFO (autoRegister: false, ocppProtocol: OCPPProtocol.JSON) alongside currentOutType and ocppVersion. getStationInfo applies them via mergeDeepRight(DEFAULT_STATION_INFO, stationInfo), so an explicit template/file value or option still wins (idempotent, no clobber) and a persisted config predating the fields is backfilled on reload. Export OCPPProtocol from the types barrel to keep Constants.ts importing types from a single source, as OCPPVersion already does. Runtime-neutral: ocppProtocol has no runtime read; the autoRegister === true guards are unaffected by false vs undefined; the getHeartbeatInterval warn guarded by === false is unreachable in the initialized pipeline because initializeOcppConfiguration always seeds the HeartbeatInterval key first. * refactor(test): extract persisted-config resolution into realStation helpers The persisted configuration file/dir resolution from a real-station template was inlined and duplicated across three suites (AutoRegisterOcppProtocol, NumberOfPhases, ResetIdentity). Extract persistedConfigurationDir and resolvePersistedConfigurationFile into StationHelpers.realStation.ts (single source of truth for the temp-dir layout) and migrate all three call sites. resolvePersistedConfigurationFile throws a descriptive error when no config exists yet, replacing the silent `?? ''` fallback (which degraded into EISDIR). Group the helper by concern (temp-dir lifecycle / construction / persisted-config resolution) with section headers, and document the two new helpers in TEST_STYLE_GUIDE.md. * docs(test): harmonize helper-table param placeholders in TEST_STYLE_GUIDE --- src/types/index.ts | 1 + src/utils/Constants.ts | 3 + tests/TEST_STYLE_GUIDE.md | 14 ++- ...ngStation-AutoRegisterOcppProtocol.test.ts | 113 ++++++++++++++++++ .../ChargingStation-NumberOfPhases.test.ts | 10 +- .../ChargingStation-ResetIdentity.test.ts | 5 +- .../helpers/StationHelpers.realStation.ts | 71 +++++++++-- 7 files changed, 190 insertions(+), 27 deletions(-) create mode 100644 tests/charging-station/ChargingStation-AutoRegisterOcppProtocol.test.ts diff --git a/src/types/index.ts b/src/types/index.ts index e832da5d..d371d6ce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -373,6 +373,7 @@ export { MeterValueUnit, type SampledValue, } from './ocpp/MeterValues.js' +export { OCPPProtocol } from './ocpp/OCPPProtocol.js' export { OCPPVersion } from './ocpp/OCPPVersion.js' export { AvailabilityType, diff --git a/src/utils/Constants.ts b/src/utils/Constants.ts index d2f05726..7e277aa8 100644 --- a/src/utils/Constants.ts +++ b/src/utils/Constants.ts @@ -2,6 +2,7 @@ import { type AutomaticTransactionGeneratorConfiguration, type ChargingStationInfo, CurrentType, + OCPPProtocol, OCPPVersion, VendorParametersKey, } from '../types/index.js' @@ -81,6 +82,7 @@ export class Constants { static readonly DEFAULT_STATION_INFO: Readonly> = Object.freeze({ automaticTransactionGeneratorPersistentConfiguration: true, autoReconnectMaxRetries: -1, + autoRegister: false, autoStart: true, beginEndMeterValues: false, currentOutType: CurrentType.AC, @@ -99,6 +101,7 @@ export class Constants { mainVoltageMeterValues: true, meteringPerTransaction: true, ocppPersistentConfiguration: true, + ocppProtocol: OCPPProtocol.JSON, ocppStrictCompliance: true, ocppVersion: OCPPVersion.VERSION_16, outOfOrderEndMeterValues: false, diff --git a/tests/TEST_STYLE_GUIDE.md b/tests/TEST_STYLE_GUIDE.md index 6d36002f..37d50b2e 100644 --- a/tests/TEST_STYLE_GUIDE.md +++ b/tests/TEST_STYLE_GUIDE.md @@ -327,12 +327,14 @@ Tests exercising the real construction pipeline (persistence, reset, reconnect, parsing) MUST build a real station from a template file via `helpers/StationHelpers.realStation.ts` — never re-implement the temp-dir scaffolding: -| Helper | Purpose | -| ----------------------------- | ----------------------------------------------------------- | -| `writeStationTemplate(obj)` | Write an inline template object into an isolated temp dir | -| `copyStationTemplate(ovr?)` | Copy a bundled asset template, optionally merging overrides | -| `createStationFromTemplate()` | Construct a real `ChargingStation` from a template file | -| `cleanupStationTemplates()` | Remove temp template dirs (call in `afterEach`) | +| Helper | Purpose | +| ----------------------------------------- | ----------------------------------------------------------- | +| `writeStationTemplate(obj)` | Write an inline template object into an isolated temp dir | +| `copyStationTemplate(ovr?)` | Copy a bundled asset template, optionally merging overrides | +| `cleanupStationTemplates()` | Remove temp template dirs (call in `afterEach`) | +| `createStationFromTemplate()` | Construct a real `ChargingStation` from a template file | +| `persistedConfigurationDir(file)` | Resolve the sibling `configurations` dir of a template file | +| `resolvePersistedConfigurationFile(file)` | Resolve the persisted config file (throws if none yet) | ```typescript afterEach(() => { diff --git a/tests/charging-station/ChargingStation-AutoRegisterOcppProtocol.test.ts b/tests/charging-station/ChargingStation-AutoRegisterOcppProtocol.test.ts new file mode 100644 index 00000000..64df91db --- /dev/null +++ b/tests/charging-station/ChargingStation-AutoRegisterOcppProtocol.test.ts @@ -0,0 +1,113 @@ +/** + * @file Tests for `autoRegister` and `ocppProtocol` default seeding into `stationInfo`. + * @description Both fields are static defaults with no derivation, so they live in + * `DEFAULT_STATION_INFO` (`autoRegister: false`, `ocppProtocol: OCPPProtocol.JSON`) + * and are applied by `getStationInfo` via `mergeDeepRight(DEFAULT_STATION_INFO, stationInfo)`. + * A template omitting them must yield the seeded values in `stationInfo` and in the UI + * data payload (`buildAddedMessage`) instead of `undefined` (the empty Web UI placeholder). + * An explicit template value is preserved (idempotent, no clobber), and a persisted + * configuration predating the fields is backfilled on reload. `ocppProtocol` preservation + * is not tested: the enum has a single value, so an explicit value is indistinguishable + * from the default (non-discriminant). + */ +import assert from 'node:assert/strict' +import { readFileSync, writeFileSync } from 'node:fs' +import { afterEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' + +import { OCPPProtocol } from '../../src/types/index.js' +import { buildAddedMessage } from '../../src/utils/MessageChannelUtils.js' +import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + cleanupStationTemplates, + createStationFromTemplate, + resolvePersistedConfigurationFile, + writeStationTemplate, +} from './helpers/StationHelpers.realStation.js' + +const POWER_W = 22000 + +interface TemplateOverrides { + autoRegister?: boolean +} + +const buildTemplate = (overrides: TemplateOverrides = {}): Record => ({ + $schemaVersion: 1, + baseName: 'TEST-AUTO-REGISTER-OCPP-PROTOCOL', + chargePointModel: 'Simulator simple', + chargePointVendor: 'Simulator', + Connectors: { + 0: {}, + 1: { bootStatus: 'Available' }, + }, + currentOutType: 'AC', + numberOfConnectors: 1, + power: POWER_W, + powerUnit: 'W', + randomConnectors: false, + ...(overrides.autoRegister != null ? { autoRegister: overrides.autoRegister } : {}), +}) + +const makeStation = (templateFile: string, persistentConfiguration = false): ChargingStation => + createStationFromTemplate(templateFile, { + baseName: 'TEST-AUTO-REGISTER-OCPP-PROTOCOL', + fixedName: true, + persistentConfiguration, + }) + +const newStation = (overrides: TemplateOverrides = {}): ChargingStation => + makeStation(writeStationTemplate(buildTemplate(overrides))) + +await describe('ChargingStation autoRegister/ocppProtocol seeding', async () => { + afterEach(() => { + standardCleanup() + cleanupStationTemplates() + }) + + await it('should seed autoRegister to false for a template omitting the field', () => { + const station = newStation() + assert.strictEqual(station.stationInfo?.autoRegister, false) + }) + + await it('should seed ocppProtocol to json for a template omitting the field', () => { + const station = newStation() + assert.strictEqual(station.stationInfo?.ocppProtocol, OCPPProtocol.JSON) + }) + + await it('should transmit the seeded defaults in the UI data payload', () => { + const station = newStation() + const { stationInfo } = buildAddedMessage(station).data + assert.strictEqual(stationInfo.autoRegister, false) + assert.strictEqual(stationInfo.ocppProtocol, OCPPProtocol.JSON) + }) + + await it('should preserve an explicit autoRegister template value', () => { + const station = newStation({ autoRegister: true }) + assert.strictEqual(station.stationInfo?.autoRegister, true) + }) + + await it('should backfill the defaults into a persisted config that predates the fields', async () => { + const templateFile = writeStationTemplate(buildTemplate()) + // The persisted config write runs under an async lock; flush before reading it back. + makeStation(templateFile, true) + await flushMicrotasks() + // Simulate a legacy configuration written before the fields were seeded. + const configurationFile = resolvePersistedConfigurationFile(templateFile) + const configuration = JSON.parse(readFileSync(configurationFile, 'utf8')) as { + stationInfo: { autoRegister?: boolean; ocppProtocol?: string } + } + assert.strictEqual(configuration.stationInfo.autoRegister, false) + assert.strictEqual(configuration.stationInfo.ocppProtocol, OCPPProtocol.JSON) + delete configuration.stationInfo.autoRegister + delete configuration.stationInfo.ocppProtocol + writeFileSync(configurationFile, JSON.stringify(configuration), 'utf8') + // A fresh station starts with an empty configurationFileHash, so getConfigurationFromFile + // bypasses the shared cache and reads the file from disk; the file-sourced stationInfo + // is backfilled by mergeDeepRight(DEFAULT_STATION_INFO, stationInfo) in getStationInfo. + const reloaded = makeStation(templateFile, true) + assert.ok(reloaded.stationInfo) + assert.strictEqual(reloaded.stationInfo.autoRegister, false) + assert.strictEqual(reloaded.stationInfo.ocppProtocol, OCPPProtocol.JSON) + }) +}) diff --git a/tests/charging-station/ChargingStation-NumberOfPhases.test.ts b/tests/charging-station/ChargingStation-NumberOfPhases.test.ts index 920089c7..db6e0907 100644 --- a/tests/charging-station/ChargingStation-NumberOfPhases.test.ts +++ b/tests/charging-station/ChargingStation-NumberOfPhases.test.ts @@ -10,8 +10,7 @@ * backfilled on reload. */ import assert from 'node:assert/strict' -import { readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { readFileSync, writeFileSync } from 'node:fs' import { afterEach, describe, it } from 'node:test' import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' @@ -21,6 +20,7 @@ import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelper import { cleanupStationTemplates, createStationFromTemplate, + resolvePersistedConfigurationFile, writeStationTemplate, } from './helpers/StationHelpers.realStation.js' @@ -103,11 +103,7 @@ await describe('ChargingStation numberOfPhases seeding', async () => { makeStation(templateFile, true) await flushMicrotasks() // Simulate a legacy configuration written before numberOfPhases was seeded. - const configurationDir = join(dirname(dirname(templateFile)), 'configurations') - const configurationFile = join( - configurationDir, - readdirSync(configurationDir).find(entry => entry.endsWith('.json')) ?? '' - ) + const configurationFile = resolvePersistedConfigurationFile(templateFile) const configuration = JSON.parse(readFileSync(configurationFile, 'utf8')) as { stationInfo: { numberOfPhases?: number } } diff --git a/tests/charging-station/ChargingStation-ResetIdentity.test.ts b/tests/charging-station/ChargingStation-ResetIdentity.test.ts index 9d0ff0b8..1a2fc72a 100644 --- a/tests/charging-station/ChargingStation-ResetIdentity.test.ts +++ b/tests/charging-station/ChargingStation-ResetIdentity.test.ts @@ -6,7 +6,7 @@ */ import assert from 'node:assert/strict' import { existsSync, readdirSync, readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, it } from 'node:test' import { setTimeout as sleep } from 'node:timers/promises' @@ -19,6 +19,7 @@ import { cleanupStationTemplates, copyStationTemplate, createStationFromTemplate, + persistedConfigurationDir, } from './helpers/StationHelpers.realStation.js' // The identity logic lives in initialize(); the identity tests call it directly @@ -41,7 +42,7 @@ const waitForPersistedId = async ( templateFile: string, chargingStationId: string ): Promise => { - const configurationsDir = dirname(templateFile.replace('station-templates', 'configurations')) + const configurationsDir = persistedConfigurationDir(templateFile) for (let attempt = 0; attempt < 100; attempt++) { if (existsSync(configurationsDir)) { for (const file of readdirSync(configurationsDir)) { diff --git a/tests/charging-station/helpers/StationHelpers.realStation.ts b/tests/charging-station/helpers/StationHelpers.realStation.ts index c5865585..87e32a70 100644 --- a/tests/charging-station/helpers/StationHelpers.realStation.ts +++ b/tests/charging-station/helpers/StationHelpers.realStation.ts @@ -2,15 +2,25 @@ * @file Helpers to build a real ChargingStation from a template file. * @description Tests that exercise the real construction pipeline * (`initialize()`/`getStationInfo()`, persistence, reset, reconnect) cannot use - * the `createMockChargingStation` stub, which bypasses it. These helpers write a - * template into an isolated temp `station-templates` dir and construct a real - * `ChargingStation` from it, tracking temp roots for a single `afterEach` - * cleanup. Two template sources are supported: an inline object - * (`writeStationTemplate`) and a bundled asset (`copyStationTemplate`). + * the `createMockChargingStation` stub, which bypasses it. Grouped by concern: + * (1) temp `station-templates` dir lifecycle — write an inline template + * (`writeStationTemplate`) or copy a bundled asset (`copyStationTemplate`), + * removed by a single `afterEach` `cleanupStationTemplates`; (2) real + * `ChargingStation` construction (`createStationFromTemplate`); (3) resolution of + * the persisted configuration the station writes (`persistedConfigurationDir`, + * `resolvePersistedConfigurationFile`). */ -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import type { ChargingStationOptions } from '../../../src/types/index.js' @@ -29,6 +39,10 @@ const freshTemplateDir = (): string => { return root } +// --------------------------------------------------------------- +// Temp template dir lifecycle +// --------------------------------------------------------------- + /** * Writes an inline template object into a fresh isolated `station-templates` dir. * @param template - Template object to serialize. @@ -66,6 +80,20 @@ export const copyStationTemplate = ( return file } +/** + * Removes every temp template dir created by `writeStationTemplate`/`copyStationTemplate`. + * Call in `afterEach`, alongside `standardCleanup()`. + */ +export const cleanupStationTemplates = (): void => { + for (const root of templateRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }) + } +} + +// --------------------------------------------------------------- +// Real station construction +// --------------------------------------------------------------- + /** * Constructs a real ChargingStation from a template file with test defaults * (`autoStart` off, local supervision URL); caller options take precedence. @@ -85,12 +113,31 @@ export const createStationFromTemplate = ( ...options, }) +// --------------------------------------------------------------- +// Persisted configuration resolution +// --------------------------------------------------------------- + /** - * Removes every temp template dir created by `writeStationTemplate`/`copyStationTemplate`. - * Call in `afterEach`, alongside `standardCleanup()`. + * Resolves the isolated `configurations` dir sitting beside the + * `station-templates` dir of a template file — where a persisted + * ChargingStation writes its configuration. + * @param templateFile - Path returned by `writeStationTemplate`/`copyStationTemplate`. + * @returns Absolute path to the sibling `configurations` dir. */ -export const cleanupStationTemplates = (): void => { - for (const root of templateRoots.splice(0)) { - rmSync(root, { force: true, recursive: true }) +export const persistedConfigurationDir = (templateFile: string): string => + join(dirname(dirname(templateFile)), 'configurations') + +/** + * Resolves the single persisted configuration file written by a station built + * from `templateFile`. Throws when none exists yet (flush the async write first). + * @param templateFile - Path returned by `writeStationTemplate`/`copyStationTemplate`. + * @returns Absolute path to the persisted configuration `.json` file. + */ +export const resolvePersistedConfigurationFile = (templateFile: string): string => { + const configurationDir = persistedConfigurationDir(templateFile) + const configurationFile = readdirSync(configurationDir).find(entry => entry.endsWith('.json')) + if (configurationFile == null) { + throw new Error(`No persisted configuration file found in ${configurationDir}`) } + return join(configurationDir, configurationFile) } -- 2.53.0