]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(simulator): seed numberOfPhases default into stationInfo (#2078)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 11 Aug 2026 22:09:54 +0000 (00:09 +0200)
committerGitHub <noreply@github.com>
Tue, 11 Aug 2026 22:09:54 +0000 (00:09 +0200)
* fix(simulator): seed numberOfPhases default into stationInfo

The derived numberOfPhases default (AC: 3, DC: 0) lived only in the
getNumberOfPhases getter and was never written to stationInfo. Raw
consumers of stationInfo — the UI data payload (buildChargingStationDataPayload)
and the persisted configuration file — therefore received an undefined
numberOfPhases, so the Web UI showed an empty placeholder instead of the
effective phase count.

Seed stationInfo.numberOfPhases via the existing getNumberOfPhases getter
in getStationInfo, post-merge and source-agnostic, so both freshly
template-derived and already-persisted (legacy) configurations are fixed.
Idempotent: an explicit AC template value is preserved (?? 3), DC pins 0.
Backend consumers keep reading the getter and are invariant.

* test(simulator): align numberOfPhases test names with should-prefix convention

* test(simulator): cover DC phase pinning and persisted-config backfill

Add two discriminant cases to the numberOfPhases seeding suite:
- DC pins numberOfPhases to 0 even when the template sets a value
- a persisted configuration predating the field is backfilled on reload
  (the file-sourced path that motivated seeding in the orchestrator)

* docs(simulator): harmonize numberOfPhases seeding review nits

- reuse the canonical flushMicrotasks test helper instead of a local
  node:timers/promises import
- expand the test @file description to cover DC pinning and legacy backfill
- align the seed comment terminology on "backfill"

* test(simulator): dedupe test baseName literal into a constant

* docs(simulator): tighten seeding comments (drop getter paraphrase)

* docs(simulator): fix backfill comment accuracy and align seed wording

* test(simulator): inline baseName to match sibling station-test convention

* test(simulator): consolidate real-station-from-template scaffolding into a shared helper

Six charging-station tests each hand-rolled the same temp-dir template
scaffolding to build a real ChargingStation (the mock factory bypasses
initialize()/getStationInfo()). Extract writeStationTemplate/copyStationTemplate/
createStationFromTemplate/cleanupStationTemplates into StationHelpers.realStation,
migrate all six tests, and document the helper in TEST_STYLE_GUIDE.

* test(simulator): share temp-file and singleton-reset test helpers

Extract the mkdtemp/write/cleanup boilerplate into tests/helpers/TempFiles.ts
(createTempDir/writeTempFile/cleanupTempDirs) and a resetSingleton() helper into
TestLifecycleHelpers, then migrate the file-I/O tests (IdTagsCache, EvProfiles,
JsonFileStorage, Configuration-HotReload, FileUtils, UIMCPServer integration) and
the singleton-reset tests (Bootstrap, SharedLRUCache, IdTagsCache) onto them.

* docs(test): document TempFiles helpers and resetSingleton in the style guide

20 files changed:
src/charging-station/ChargingStation.ts
tests/TEST_STYLE_GUIDE.md
tests/charging-station/Bootstrap.test.ts
tests/charging-station/ChargingStation-ConfigurationTemplateIsolation.test.ts
tests/charging-station/ChargingStation-ConversionEfficiency.test.ts
tests/charging-station/ChargingStation-NumberOfPhases.test.ts [new file with mode: 0644]
tests/charging-station/ChargingStation-Reconnect.test.ts
tests/charging-station/ChargingStation-ResetCancellation.test.ts
tests/charging-station/ChargingStation-ResetIdentity.test.ts
tests/charging-station/IdTagsCache.test.ts
tests/charging-station/SharedLRUCache.test.ts
tests/charging-station/helpers/StationHelpers.realStation.ts [new file with mode: 0644]
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/meter-values/EvProfiles.test.ts
tests/charging-station/ui-server/UIMCPServer-Integration.test.ts
tests/helpers/TempFiles.ts [new file with mode: 0644]
tests/helpers/TestLifecycleHelpers.ts
tests/performance/storage/JsonFileStorage.test.ts
tests/utils/Configuration-HotReload.test.ts
tests/utils/FileUtils.test.ts

index f225b29d23a3786d7714a20d9de12bf9d13b17a9..4290c7da35166caf58f0299d5833e002e8c53b5a 100644 (file)
@@ -1722,6 +1722,10 @@ export class ChargingStation extends EventEmitter {
       mergeDeepRight(Constants.DEFAULT_STATION_INFO as ChargingStationInfo, stationInfo),
       options
     )
+    // getNumberOfPhases owns this derived default, but raw consumers (UI data payload,
+    // persisted configuration) read stationInfo directly. Seed it post-merge so
+    // those paths — and persisted configs predating the field — get the effective value.
+    stationInfo.numberOfPhases = this.getNumberOfPhases(stationInfo)
     stationInfo.chargingStationId = getChargingStationId(this.index, stationInfo)
     stationInfo.hashId = getHashId(this.index, stationTemplate, stationInfo.chargingStationId)
     return stationInfo
index 50cb7c261fdfc3cb0e51bab8b95c8b40d2fc908e..6d36002f508165b2460bd2436664e9704a272be9 100644 (file)
@@ -320,23 +320,55 @@ const { station, mocks } = createMockChargingStation({
 assert.strictEqual(mocks.webSocket.sentMessages.length, 1)
 ```
 
+### Real station from a template
+
+`createMockChargingStation()` is a stub that bypasses `initialize()`/`getStationInfo()`.
+Tests exercising the real construction pipeline (persistence, reset, reconnect, template
+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`)             |
+
+```typescript
+afterEach(() => {
+  standardCleanup()
+  cleanupStationTemplates()
+})
+
+const station = createStationFromTemplate(copyStationTemplate())
+```
+
 ---
 
 ## 10. Utility Reference
 
 ### Lifecycle Helpers (`helpers/TestLifecycleHelpers.ts`)
 
-| Utility                           | Purpose                                  |
-| --------------------------------- | ---------------------------------------- |
-| `standardCleanup()`               | **MANDATORY** afterEach cleanup          |
-| `flushMicrotasks()`               | Drain async side-effects from `emit()`   |
-| `withMockTimers()`                | Execute test with timer mocking          |
-| `createTimerScope()`              | Manual timer control                     |
-| `sleep(ms)`                       | Real-time delay (avoid in tests)         |
-| `createLoggerMocks()`             | Create logger spies (error, warn)        |
-| `createConsoleMocks()`            | Create console spies (error, warn, info) |
-| `setupConnectorWithTransaction()` | Setup connector in transaction state     |
-| `clearConnectorTransaction()`     | Clear connector transaction state        |
+| Utility                           | Purpose                                    |
+| --------------------------------- | ------------------------------------------ |
+| `standardCleanup()`               | **MANDATORY** afterEach cleanup            |
+| `flushMicrotasks()`               | Drain async side-effects from `emit()`     |
+| `withMockTimers()`                | Execute test with timer mocking            |
+| `createTimerScope()`              | Manual timer control                       |
+| `sleep(ms)`                       | Real-time delay (avoid in tests)           |
+| `createLoggerMocks()`             | Create logger spies (error, warn)          |
+| `createConsoleMocks()`            | Create console spies (error, warn, info)   |
+| `setupConnectorWithTransaction()` | Setup connector in transaction state       |
+| `clearConnectorTransaction()`     | Clear connector transaction state          |
+| `resetSingleton(cls)`             | Reset a `getInstance()` singleton instance |
+
+### Temp Files (`helpers/TempFiles.ts`)
+
+| Utility             | Purpose                                             |
+| ------------------- | --------------------------------------------------- |
+| `createTempDir()`   | Create a tracked temp dir under the OS temp root    |
+| `writeTempFile()`   | Write a file into a dir (typically `createTempDir`) |
+| `cleanupTempDirs()` | Remove tracked temp dirs (call in `afterEach`)      |
 
 ### Mock Classes (`mocks/`)
 
index 222a5615cdcda8c7ab0ac5d84c672b69d551a397..a8d808f4b074d322cd0a6a4d3b71da56f5a2b31a 100644 (file)
@@ -16,7 +16,7 @@ import { setTimeout as sleep } from 'node:timers/promises'
 
 import { Bootstrap, STATE_FILE_VERSION } from '../../src/charging-station/index.js'
 import { logger } from '../../src/utils/index.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 
 interface Barrier {
   promise: Promise<void>
@@ -56,10 +56,6 @@ const createBarrier = (): Barrier => {
   return { promise, resolve: resolveFn }
 }
 
-const resetBootstrapSingleton = (): void => {
-  ;(Bootstrap as unknown as { instance: Bootstrap | null }).instance = null
-}
-
 const buildLifecycleTestInstance = (stateFilePath: string): BootstrapInternal => {
   const instance = Object.create(Bootstrap.prototype) as BootstrapInternal
   EventEmitter.call(instance as unknown as EventEmitter)
@@ -130,7 +126,7 @@ await describe('Bootstrap lifecycle state machine', async () => {
 
   afterEach(() => {
     rmSync(testDir, { force: true, recursive: true })
-    resetBootstrapSingleton()
+    resetSingleton(Bootstrap)
     mock.restoreAll()
     standardCleanup()
   })
index 224e43e8106462dc17b09d8f0645326bd893fc1d..fda25cad713343186300a2f48c1a720b2902c89a 100644 (file)
@@ -5,20 +5,22 @@
  * local and does not mutate the shared template held in the SharedLRUCache.
  */
 import assert from 'node:assert/strict'
-import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import type { ChargingStationTemplate } from '../../src/types/index.js'
 
-import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import {
   getConfigurationKey,
   setConfigurationKeyValue,
 } from '../../src/charging-station/ConfigurationKeyUtils.js'
 import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  copyStationTemplate,
+  createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
 
 // templateFileHash is the SharedLRUCache key; reached via a typed boundary cast (no `as any`).
 const templateHashOf = (station: ChargingStation): string =>
@@ -31,38 +33,15 @@ const configValue = (template: ChargingStationTemplate, key: string): string | u
 const CONFIG_KEY = 'MeterValueSampleInterval'
 const TEMPLATE_VALUE = '30'
 
-const tmpRoots: string[] = []
-
-// Fresh template in its own temp station-templates dir. A station caches its parsed template
-// in the SharedLRUCache under a content-derived key; templateHashOf() fetches that exact entry
-// so the test can assert the station's Configuration is an independent copy of it.
-const makeTemplate = (): string => {
-  const root = mkdtempSync(join(tmpdir(), 'cs-config-isolation-'))
-  tmpRoots.push(root)
-  mkdirSync(join(root, 'station-templates'), { recursive: true })
-  const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
-  copyFileSync(
-    join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
-    file
-  )
-  return file
-}
-
 await describe('ChargingStation OCPP Configuration isolation', async () => {
   afterEach(() => {
     standardCleanup()
-    for (const root of tmpRoots.splice(0)) {
-      rmSync(root, { force: true, recursive: true })
-    }
+    cleanupStationTemplates()
   })
 
   await it('should not mutate the shared cached template when a non-persistent station changes a configuration key', () => {
-    const templateFile = makeTemplate()
-    const station = new ChargingStation(1, templateFile, {
-      autoStart: false,
-      persistentConfiguration: false,
-      supervisionUrls: 'ws://localhost:9999/',
-    })
+    const templateFile = copyStationTemplate()
+    const station = createStationFromTemplate(templateFile, { persistentConfiguration: false })
 
     // The exact cached template the station parsed and read its Configuration from.
     const cachedTemplate = SharedLRUCache.getInstance().getChargingStationTemplate(
index 46a288200ff94657f80512feea85f77f53a3000f..a7044c6d694f478c7088b7692111d48ef047d4a1 100644 (file)
@@ -8,18 +8,19 @@
  * voltageOut` on DC) are left unchanged and are not reduced by the factor.
  */
 import assert from 'node:assert/strict'
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
-import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  createStationFromTemplate,
+  writeStationTemplate,
+} from './helpers/StationHelpers.realStation.js'
 
 const POWER_W = 50000
 
-const tmpRoots: string[] = []
-
 interface TemplateOverrides {
   connectorMaximumPower?: number
   conversionEfficiency?: number
@@ -32,14 +33,10 @@ interface TemplateOverrides {
 // station power and the station bound is the binding one; an explicit
 // connectorMaximumPower override makes the connector hardware bound binding
 // instead, to exercise the second power-derived term of the min().
-const makeTemplate = (overrides: TemplateOverrides = {}): string => {
-  const root = mkdtempSync(join(tmpdir(), 'cs-conversion-efficiency-'))
-  tmpRoots.push(root)
-  mkdirSync(join(root, 'station-templates'), { recursive: true })
-  const file = join(root, 'station-templates', 'dc.station-template.json')
+const buildTemplate = (overrides: TemplateOverrides = {}): Record<string, unknown> => {
   const connectorMaximumPower =
     overrides.connectorMaximumPower != null ? { maximumPower: overrides.connectorMaximumPower } : {}
-  const template: Record<string, unknown> = {
+  return {
     $schemaVersion: 1,
     baseName: 'TEST-CONVERSION-EFFICIENCY',
     chargePointModel: 'Simulator simple',
@@ -59,25 +56,19 @@ const makeTemplate = (overrides: TemplateOverrides = {}): string => {
       ? { conversionEfficiency: overrides.conversionEfficiency }
       : {}),
   }
-  writeFileSync(file, JSON.stringify(template), 'utf8')
-  return file
 }
 
 const newStation = (overrides: TemplateOverrides = {}): ChargingStation =>
-  new ChargingStation(1, makeTemplate(overrides), {
-    autoStart: false,
+  createStationFromTemplate(writeStationTemplate(buildTemplate(overrides)), {
     baseName: 'TEST-CONVERSION-EFFICIENCY',
     fixedName: true,
     persistentConfiguration: false,
-    supervisionUrls: 'ws://localhost:9999/',
   })
 
 await describe('ChargingStation AC/DC conversion efficiency', async () => {
   afterEach(() => {
     standardCleanup()
-    for (const root of tmpRoots.splice(0)) {
-      rmSync(root, { force: true, recursive: true })
-    }
+    cleanupStationTemplates()
   })
 
   await it('reduces the DC connector available power by the efficiency factor', () => {
diff --git a/tests/charging-station/ChargingStation-NumberOfPhases.test.ts b/tests/charging-station/ChargingStation-NumberOfPhases.test.ts
new file mode 100644 (file)
index 0000000..920089c
--- /dev/null
@@ -0,0 +1,123 @@
+/**
+ * @file Tests for `numberOfPhases` default seeding into `stationInfo`.
+ * @description The derived `numberOfPhases` default lives only in the
+ * `getNumberOfPhases` getter (AC: template value ?? 3, DC: 0) and must be
+ * seeded into `stationInfo` by `getStationInfo` so raw consumers — the UI data
+ * payload (`buildAddedMessage`) and the persisted configuration — receive the
+ * effective value instead of `undefined`. An explicit AC template value is
+ * preserved (idempotent, no clobber), DC pins 0 even against an explicit
+ * template value, and a persisted configuration predating the field is
+ * backfilled on reload.
+ */
+import assert from 'node:assert/strict'
+import { readdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
+import { buildAddedMessage } from '../../src/utils/MessageChannelUtils.js'
+import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  createStationFromTemplate,
+  writeStationTemplate,
+} from './helpers/StationHelpers.realStation.js'
+
+const POWER_W = 22000
+
+interface TemplateOverrides {
+  currentOutType?: string
+  numberOfPhases?: number
+}
+
+const buildTemplate = (overrides: TemplateOverrides = {}): Record<string, unknown> => ({
+  $schemaVersion: 1,
+  baseName: 'TEST-NUMBER-OF-PHASES',
+  chargePointModel: 'Simulator simple',
+  chargePointVendor: 'Simulator',
+  Connectors: {
+    0: {},
+    1: { bootStatus: 'Available' },
+  },
+  currentOutType: overrides.currentOutType ?? 'AC',
+  numberOfConnectors: 1,
+  power: POWER_W,
+  powerUnit: 'W',
+  randomConnectors: false,
+  ...(overrides.numberOfPhases != null ? { numberOfPhases: overrides.numberOfPhases } : {}),
+})
+
+const makeStation = (templateFile: string, persistentConfiguration = false): ChargingStation =>
+  createStationFromTemplate(templateFile, {
+    baseName: 'TEST-NUMBER-OF-PHASES',
+    fixedName: true,
+    persistentConfiguration,
+  })
+
+const newStation = (overrides: TemplateOverrides = {}): ChargingStation =>
+  makeStation(writeStationTemplate(buildTemplate(overrides)))
+
+await describe('ChargingStation numberOfPhases seeding', async () => {
+  afterEach(() => {
+    standardCleanup()
+    cleanupStationTemplates()
+  })
+
+  await it('should seed numberOfPhases to 3 for an AC template omitting the field', () => {
+    const station = newStation({ currentOutType: 'AC' })
+    assert.strictEqual(station.stationInfo?.numberOfPhases, 3)
+  })
+
+  await it('should seed numberOfPhases to 0 for a DC template', () => {
+    const station = newStation({ currentOutType: 'DC' })
+    assert.strictEqual(station.stationInfo?.numberOfPhases, 0)
+  })
+
+  await it('should preserve an explicit AC template numberOfPhases value', () => {
+    const station = newStation({ currentOutType: 'AC', numberOfPhases: 1 })
+    assert.strictEqual(station.stationInfo?.numberOfPhases, 1)
+  })
+
+  await it('should transmit the seeded numberOfPhases in the UI data payload', () => {
+    const station = newStation({ currentOutType: 'AC' })
+    const payload = buildAddedMessage(station).data
+    assert.strictEqual(payload.stationInfo.numberOfPhases, 3)
+  })
+
+  await it('should match getNumberOfPhases so backend consumers are invariant', () => {
+    const acStation = newStation({ currentOutType: 'AC' })
+    assert.strictEqual(acStation.stationInfo?.numberOfPhases, acStation.getNumberOfPhases())
+    const dcStation = newStation({ currentOutType: 'DC' })
+    assert.strictEqual(dcStation.stationInfo?.numberOfPhases, dcStation.getNumberOfPhases())
+  })
+
+  await it('should pin numberOfPhases to 0 for DC even when the template sets it', () => {
+    const station = newStation({ currentOutType: 'DC', numberOfPhases: 3 })
+    assert.strictEqual(station.stationInfo?.numberOfPhases, 0)
+  })
+
+  await it('should backfill numberOfPhases into a persisted config that predates the field', async () => {
+    const templateFile = writeStationTemplate(buildTemplate({ currentOutType: 'AC' }))
+    // 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 numberOfPhases was seeded.
+    const configurationDir = join(dirname(dirname(templateFile)), 'configurations')
+    const configurationFile = join(
+      configurationDir,
+      readdirSync(configurationDir).find(entry => entry.endsWith('.json')) ?? ''
+    )
+    const configuration = JSON.parse(readFileSync(configurationFile, 'utf8')) as {
+      stationInfo: { numberOfPhases?: number }
+    }
+    assert.strictEqual(configuration.stationInfo.numberOfPhases, 3)
+    delete configuration.stationInfo.numberOfPhases
+    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
+    // must backfill the field.
+    const reloaded = makeStation(templateFile, true)
+    assert.strictEqual(reloaded.stationInfo?.numberOfPhases, 3)
+  })
+})
index 84464f4849767caf28b57e4672c41da1cd39a559..21e855a64304037d149835ed025eca0b99a73d68 100644 (file)
@@ -5,15 +5,18 @@
  * disconnected after a requested close.
  */
 import assert from 'node:assert/strict'
-import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 import { WebSocket } from 'ws'
 
-import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+
 import { WebSocketCloseEventStatusCode } from '../../src/types/index.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  copyStationTemplate,
+  createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
 
 // onClose and reconnect are private; the tests drive onClose directly with a
 // spied reconnect to observe the reconnect decision without opening a socket.
@@ -24,22 +27,9 @@ interface StationInternals {
   wsConnection: unknown
 }
 
-const tmpRoots: string[] = []
-
 // Build a started station whose reconnect() is replaced by a counter.
 const makeStation = (): { reconnectCount: () => number; station: ChargingStation } => {
-  const root = mkdtempSync(join(tmpdir(), 'cs-reconnect-'))
-  tmpRoots.push(root)
-  mkdirSync(join(root, 'station-templates'), { recursive: true })
-  const templateFile = join(root, 'station-templates', 'virtual-simple.station-template.json')
-  copyFileSync(
-    join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
-    templateFile
-  )
-  const station = new ChargingStation(1, templateFile, {
-    autoStart: false,
-    supervisionUrls: 'ws://localhost:9999/',
-  })
+  const station = createStationFromTemplate(copyStationTemplate())
   let reconnects = 0
   const internals = station as unknown as StationInternals
   internals.reconnect = () => {
@@ -53,9 +43,7 @@ const makeStation = (): { reconnectCount: () => number; station: ChargingStation
 await describe('ChargingStation reconnect decision on WebSocket close', async () => {
   afterEach(() => {
     standardCleanup()
-    for (const root of tmpRoots.splice(0)) {
-      rmSync(root, { force: true, recursive: true })
-    }
+    cleanupStationTemplates()
   })
 
   await it('should reconnect after a server-initiated normal close while started', () => {
index 8dd47bf2ea56e09887197345bd2212d7c7d8dd0c..a5411400076ab4ae2677cec2113c88e8cb3aca9c 100644 (file)
@@ -6,38 +6,24 @@
  * CSMS (the "zombie" reconnect that triggers issue #2017).
  */
 import assert from 'node:assert/strict'
-import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import type { ChargingStationOptions } from '../../src/types/index.js'
 
-import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import {
   flushMicrotasks,
   standardCleanup,
   withMockTimers,
 } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  copyStationTemplate,
+  createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
 
 const RESET_TIME_MS = 60000
 
-const tmpRoots: string[] = []
-
-// Fresh template under its own temp station-templates dir so each test is
-// isolated, mirroring the ChargingStation-ResetIdentity harness.
-const makeTemplate = (): string => {
-  const root = mkdtempSync(join(tmpdir(), 'cs-reset-cancel-'))
-  tmpRoots.push(root)
-  mkdirSync(join(root, 'station-templates'), { recursive: true })
-  const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
-  copyFileSync(
-    join(process.cwd(), 'src/assets/station-templates/virtual-simple.station-template.json'),
-    file
-  )
-  return file
-}
-
 interface ResetInternals {
   initialize: (options?: ChargingStationOptions) => void
   start: () => void
@@ -45,12 +31,10 @@ interface ResetInternals {
 }
 
 const newStation = (resetTimeMs = RESET_TIME_MS): ChargingStation => {
-  const station = new ChargingStation(1, makeTemplate(), {
-    autoStart: false,
+  const station = createStationFromTemplate(copyStationTemplate(), {
     baseName: 'TEST-RESET-CANCEL',
     fixedName: true,
     persistentConfiguration: false,
-    supervisionUrls: 'ws://localhost:9999/',
   })
   if (station.stationInfo != null) {
     station.stationInfo.resetTime = resetTimeMs
@@ -61,9 +45,7 @@ const newStation = (resetTimeMs = RESET_TIME_MS): ChargingStation => {
 await describe('ChargingStation cancellable reset', async () => {
   afterEach(() => {
     standardCleanup()
-    for (const root of tmpRoots.splice(0)) {
-      rmSync(root, { force: true, recursive: true })
-    }
+    cleanupStationTemplates()
   })
 
   await it('should not re-initialize or reconnect when deleted during the reset sleep window', async t => {
index fea520f9702b9853f1eb4621d99e684281008b6e..9d0ff0b854e287ed22cd0dc6c8bd5c2e81185df9 100644 (file)
@@ -5,26 +5,21 @@
  * non-persistent one only keeps it when the creation options are re-applied.
  */
 import assert from 'node:assert/strict'
-import {
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  mkdtempSync,
-  readdirSync,
-  readFileSync,
-  rmSync,
-  writeFileSync,
-} from 'node:fs'
-import { tmpdir } from 'node:os'
+import { existsSync, readdirSync, readFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 import { setTimeout as sleep } from 'node:timers/promises'
 
+import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import type { ChargingStationOptions } from '../../src/types/index.js'
 
-import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
 import { SharedLRUCache } from '../../src/charging-station/SharedLRUCache.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+  cleanupStationTemplates,
+  copyStationTemplate,
+  createStationFromTemplate,
+} from './helpers/StationHelpers.realStation.js'
 
 // The identity logic lives in initialize(); the identity tests call it directly
 // to avoid reset()'s stop/sleep/start (which would dial a socket). A separate
@@ -39,30 +34,6 @@ const internalsOf = (station: ChargingStation): StationInternals =>
 const identityOf = (station: ChargingStation): string | undefined =>
   station.stationInfo?.chargingStationId
 
-const tmpRoots: string[] = []
-
-// Fresh template under its own temp station-templates dir, so each test's
-// persisted config lands in an isolated sibling configurations dir. Optional
-// overrides are merged into the template's top-level fields (e.g. to enable the
-// OCPP-config supervision URL mechanism).
-const makeTemplate = (overrides?: Record<string, boolean | number | string>): string => {
-  const root = mkdtempSync(join(tmpdir(), 'cs-reset-identity-'))
-  tmpRoots.push(root)
-  mkdirSync(join(root, 'station-templates'), { recursive: true })
-  const file = join(root, 'station-templates', 'virtual-simple.station-template.json')
-  const source = join(
-    process.cwd(),
-    'src/assets/station-templates/virtual-simple.station-template.json'
-  )
-  if (overrides == null) {
-    copyFileSync(source, file)
-  } else {
-    const template = JSON.parse(readFileSync(source, 'utf8')) as Record<string, unknown>
-    writeFileSync(file, JSON.stringify({ ...template, ...overrides }, null, 2))
-  }
-  return file
-}
-
 // Config writes are asynchronous, so wait until the identity has been persisted.
 // The configurations dir sits beside the station-templates dir (same derivation
 // the station uses to place its config file).
@@ -90,9 +61,7 @@ const waitForPersistedId = async (
 await describe('ChargingStation keeps its identity across a reset', async () => {
   afterEach(() => {
     standardCleanup()
-    for (const root of tmpRoots.splice(0)) {
-      rmSync(root, { force: true, recursive: true })
-    }
+    cleanupStationTemplates()
   })
 
   await it('should keep identity only when the creation options are re-applied (non-persistent)', () => {
@@ -103,7 +72,7 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
       persistentConfiguration: false,
       supervisionUrls: 'ws://localhost:9999/',
     }
-    const station = new ChargingStation(1, makeTemplate(), options)
+    const station = createStationFromTemplate(copyStationTemplate(), options)
     assert.strictEqual(identityOf(station), 'TEST-RESET-ID')
 
     // With no options and no saved config to fall back to, the station reverts
@@ -117,13 +86,11 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
   })
 
   await it('should keep identity without re-applying the creation options (persistent)', async () => {
-    const templateFile = makeTemplate()
-    const station = new ChargingStation(1, templateFile, {
-      autoStart: false,
+    const templateFile = copyStationTemplate()
+    const station = createStationFromTemplate(templateFile, {
       baseName: 'TEST-PERSIST-ID',
       fixedName: true,
       persistentConfiguration: true,
-      supervisionUrls: 'ws://localhost:9999/',
     })
     assert.strictEqual(identityOf(station), 'TEST-PERSIST-ID')
     assert.ok(
@@ -143,12 +110,10 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
   // nor dials a socket, and the reset delay is zeroed.
   for (const persistentConfiguration of [false, true]) {
     await it(`should re-apply the creation options to initialize() only when non-persistent (persistent=${persistentConfiguration.toString()})`, async t => {
-      const station = new ChargingStation(1, makeTemplate(), {
-        autoStart: false,
+      const station = createStationFromTemplate(copyStationTemplate(), {
         baseName: 'TEST-RESET-WIRING',
         fixedName: true,
         persistentConfiguration,
-        supervisionUrls: 'ws://localhost:9999/',
       })
       if (station.stationInfo != null) {
         station.stationInfo.resetTime = 0
@@ -172,10 +137,8 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
   }
 
   await it('should keep the setSupervisionUrl URL across a reset via the retained options', () => {
-    const station = new ChargingStation(1, makeTemplate(), {
-      autoStart: false,
+    const station = createStationFromTemplate(copyStationTemplate(), {
       persistentConfiguration: false,
-      supervisionUrls: 'ws://localhost:9999/',
     })
     station.setSupervisionUrl('ws://localhost:8888/')
 
@@ -193,13 +156,12 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
     // supervisionUrlOcppConfiguration routes the URL through an OCPP config key
     // rather than stationInfo.supervisionUrls, and must be a template field to
     // survive the reset rebuild.
-    const station = new ChargingStation(
-      1,
-      makeTemplate({
+    const station = createStationFromTemplate(
+      copyStationTemplate({
         supervisionUrlOcppConfiguration: true,
         supervisionUrlOcppKey: 'ConnectionUrl',
       }),
-      { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' }
+      { persistentConfiguration: false }
     )
     station.setSupervisionUrl('ws://localhost:7777/')
     if (station.stationInfo != null) {
@@ -221,13 +183,12 @@ await describe('ChargingStation keeps its identity across a reset', async () =>
   })
 
   await it('should re-seed an OCPP-config supervision URL from the retained options on template reload (non-persistent)', () => {
-    const station = new ChargingStation(
-      1,
-      makeTemplate({
+    const station = createStationFromTemplate(
+      copyStationTemplate({
         supervisionUrlOcppConfiguration: true,
         supervisionUrlOcppKey: 'ConnectionUrl',
       }),
-      { autoStart: false, persistentConfiguration: false, supervisionUrls: 'ws://localhost:9999/' }
+      { persistentConfiguration: false }
     )
     station.setSupervisionUrl('ws://localhost:7777/')
 
index 6893dd4c5db9a77f2a30b9a3ddce2f034910734d..285cdf1dd69adb96a648c07c138daa4fe9339e05 100644 (file)
@@ -10,9 +10,6 @@
  */
 
 import assert from 'node:assert/strict'
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../src/charging-station/index.js'
@@ -20,7 +17,8 @@ import type { ChargingStation } from '../../src/charging-station/index.js'
 import { getIdTagsFile } from '../../src/charging-station/index.js'
 import { IdTagsCache } from '../../src/charging-station/index.js'
 import { IdTagDistribution } from '../../src/types/index.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { cleanupTempDirs, createTempDir, writeTempFile } from '../helpers/TempFiles.js'
+import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import { createMockChargingStation } from './helpers/StationHelpers.js'
 
 const TEST_ID_TAGS = ['TAG-001', 'TAG-002', 'TAG-003']
@@ -42,13 +40,6 @@ function populateCache (cache: IdTagsCache, file: string, idTags: string[]): voi
   internal.idTagsCaches.set(file, { idTags, idTagsFileWatcher: undefined })
 }
 
-/**
- * Resets the IdTagsCache singleton so subsequent getInstance() creates a fresh cache.
- */
-function resetIdTagsCache (): void {
-  ;(IdTagsCache as unknown as { instance: null }).instance = null
-}
-
 /**
  * Resolves the idTags file path for a mock station, throwing if unresolvable.
  * @param station - The station whose stationInfo is used
@@ -69,7 +60,8 @@ function resolveIdTagsFile (station: ChargingStation): string {
 await describe('IdTagsCache', async () => {
   afterEach(() => {
     standardCleanup()
-    resetIdTagsCache()
+    resetSingleton(IdTagsCache)
+    cleanupTempDirs()
   })
 
   await describe('getInstance', async () => {
@@ -82,7 +74,7 @@ await describe('IdTagsCache', async () => {
 
     await it('should create new instance after reset', () => {
       const instance1 = IdTagsCache.getInstance()
-      resetIdTagsCache()
+      resetSingleton(IdTagsCache)
       const instance2 = IdTagsCache.getInstance()
 
       assert.notStrictEqual(instance1, instance2)
@@ -101,19 +93,16 @@ await describe('IdTagsCache', async () => {
     })
 
     await it('should load id tags from file when cache is empty', () => {
-      const tmpDir = mkdtempSync(join(tmpdir(), 'idtags-test-'))
-      const idTagsFile = join(tmpDir, 'idtags.json')
-      writeFileSync(idTagsFile, JSON.stringify(TEST_ID_TAGS))
-
-      try {
-        const cache = IdTagsCache.getInstance()
-        const result = cache.getIdTags(idTagsFile)
-
-        assert.deepStrictEqual(result, TEST_ID_TAGS)
-        cache.deleteIdTags(idTagsFile)
-      } finally {
-        rmSync(tmpDir, { force: true, recursive: true })
-      }
+      const idTagsFile = writeTempFile(
+        createTempDir('idtags-test-'),
+        'idtags.json',
+        JSON.stringify(TEST_ID_TAGS)
+      )
+      const cache = IdTagsCache.getInstance()
+      const result = cache.getIdTags(idTagsFile)
+
+      assert.deepStrictEqual(result, TEST_ID_TAGS)
+      cache.deleteIdTags(idTagsFile)
     })
 
     await it('should return empty array for empty file path', () => {
index caae640984f1bf03dfd7ba6c080e0eec2b5397d8..f0dbd86965d0bf0828c3189f3104795524aa856a 100644 (file)
@@ -21,7 +21,7 @@ import { Bootstrap } from '../../src/charging-station/index.js'
 import { SharedLRUCache } from '../../src/charging-station/index.js'
 import { StandardParametersKey } from '../../src/types/index.js'
 import { Constants } from '../../src/utils/index.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { resetSingleton, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 
 interface BootstrapStatic {
   instance: Bootstrap | null
@@ -69,13 +69,6 @@ function installMockBootstrap (): void {
   } as unknown as Bootstrap
 }
 
-/**
- * Resets the SharedLRUCache singleton so subsequent getInstance() creates a fresh cache.
- */
-function resetSharedLRUCache (): void {
-  ;(SharedLRUCache as unknown as { instance: null }).instance = null
-}
-
 await describe('SharedLRUCache', async () => {
   beforeEach(() => {
     installMockBootstrap()
@@ -83,8 +76,8 @@ await describe('SharedLRUCache', async () => {
 
   afterEach(() => {
     standardCleanup()
-    resetSharedLRUCache()
-    ;(Bootstrap as unknown as BootstrapStatic).instance = null
+    resetSingleton(SharedLRUCache)
+    resetSingleton(Bootstrap)
   })
 
   await describe('getInstance', async () => {
@@ -97,7 +90,7 @@ await describe('SharedLRUCache', async () => {
 
     await it('should create new instance after reset', () => {
       const instance1 = SharedLRUCache.getInstance()
-      resetSharedLRUCache()
+      resetSingleton(SharedLRUCache)
       const instance2 = SharedLRUCache.getInstance()
 
       assert.notStrictEqual(instance1, instance2)
diff --git a/tests/charging-station/helpers/StationHelpers.realStation.ts b/tests/charging-station/helpers/StationHelpers.realStation.ts
new file mode 100644 (file)
index 0000000..c586558
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * @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`).
+ */
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import type { ChargingStationOptions } from '../../../src/types/index.js'
+
+import { ChargingStation } from '../../../src/charging-station/ChargingStation.js'
+
+const TEST_SUPERVISION_URL = 'ws://localhost:9999/'
+const ASSET_TEMPLATES_DIR = join(process.cwd(), 'src', 'assets', 'station-templates')
+const DEFAULT_ASSET_TEMPLATE = 'virtual-simple.station-template.json'
+
+const templateRoots: string[] = []
+
+const freshTemplateDir = (): string => {
+  const root = mkdtempSync(join(tmpdir(), 'cs-test-'))
+  templateRoots.push(root)
+  mkdirSync(join(root, 'station-templates'), { recursive: true })
+  return root
+}
+
+/**
+ * Writes an inline template object into a fresh isolated `station-templates` dir.
+ * @param template - Template object to serialize.
+ * @param fileName - Template file name (cosmetic; the config path derives from the dir).
+ * @returns Absolute path to the written template file.
+ */
+export const writeStationTemplate = (
+  template: Record<string, unknown>,
+  fileName = 'test.station-template.json'
+): string => {
+  const file = join(freshTemplateDir(), 'station-templates', fileName)
+  writeFileSync(file, JSON.stringify(template), 'utf8')
+  return file
+}
+
+/**
+ * Copies a bundled asset template into a fresh isolated `station-templates` dir,
+ * optionally merging top-level overrides.
+ * @param overrides - Top-level fields merged into the asset template.
+ * @param assetFileName - Asset template file name under `src/assets/station-templates`.
+ * @returns Absolute path to the template file.
+ */
+export const copyStationTemplate = (
+  overrides?: Record<string, unknown>,
+  assetFileName = DEFAULT_ASSET_TEMPLATE
+): string => {
+  const file = join(freshTemplateDir(), 'station-templates', assetFileName)
+  const source = join(ASSET_TEMPLATES_DIR, assetFileName)
+  if (overrides == null) {
+    copyFileSync(source, file)
+  } else {
+    const template = JSON.parse(readFileSync(source, 'utf8')) as Record<string, unknown>
+    writeFileSync(file, JSON.stringify({ ...template, ...overrides }), 'utf8')
+  }
+  return file
+}
+
+/**
+ * Constructs a real ChargingStation from a template file with test defaults
+ * (`autoStart` off, local supervision URL); caller options take precedence.
+ * @param templateFile - Path returned by `writeStationTemplate`/`copyStationTemplate`.
+ * @param options - Charging station options merged over the defaults.
+ * @param index - Station index.
+ * @returns The constructed ChargingStation.
+ */
+export const createStationFromTemplate = (
+  templateFile: string,
+  options: ChargingStationOptions = {},
+  index = 1
+): ChargingStation =>
+  new ChargingStation(index, templateFile, {
+    autoStart: false,
+    supervisionUrls: TEST_SUPERVISION_URL,
+    ...options,
+  })
+
+/**
+ * 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 })
+  }
+}
index 57f1cb9b5d70eab79ba10c09bfe563c37b06f576..372b99b5001d7534c8a6248b753a02d6211c7093 100644 (file)
@@ -14,5 +14,6 @@
 export * from './StationHelpers.cleanup.js'
 export * from './StationHelpers.connector.js'
 export * from './StationHelpers.factory.js'
+export * from './StationHelpers.realStation.js'
 export * from './StationHelpers.template.js'
 export * from './StationHelpers.types.js'
index cf9a1b4ea76150db5e481da05ca88bb4b1ed4acc..2945517a7014775d186f6df24d544c098729f1c9 100644 (file)
@@ -10,9 +10,6 @@
  */
 
 import assert from 'node:assert/strict'
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
 import type { EvProfile } from '../../../src/charging-station/meter-values/types.js'
@@ -22,6 +19,7 @@ import {
   loadEvProfilesFile,
   selectEvProfile,
 } from '../../../src/charging-station/meter-values/EvProfiles.js'
+import { cleanupTempDirs, createTempDir, writeTempFile } from '../../helpers/TempFiles.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 
 const midProfile: EvProfile = {
@@ -41,6 +39,7 @@ const midProfile: EvProfile = {
 await describe('EvProfiles', async () => {
   afterEach(() => {
     standardCleanup()
+    cleanupTempDirs()
   })
   await describe('interpolateChargingCurve', async () => {
     await it('should return endpoint value at the lower boundary', () => {
@@ -105,22 +104,15 @@ await describe('EvProfiles', async () => {
     })
 
     await it('should return undefined on invalid JSON (fail-soft)', () => {
-      const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
-      const path = join(dir, 'bad.json')
-      writeFileSync(path, '{not json}')
-      try {
-        const result = loadEvProfilesFile(path, 'test')
-        assert.strictEqual(result, undefined)
-      } finally {
-        rmSync(dir, { force: true, recursive: true })
-      }
+      const path = writeTempFile(createTempDir('ev-profiles-'), 'bad.json', '{not json}')
+      const result = loadEvProfilesFile(path, 'test')
+      assert.strictEqual(result, undefined)
     })
 
     await it('should return undefined on schema violation', () => {
-      const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
-      const path = join(dir, 'bad-schema.json')
-      writeFileSync(
-        path,
+      const path = writeTempFile(
+        createTempDir('ev-profiles-'),
+        'bad-schema.json',
         JSON.stringify({
           profiles: [
             {
@@ -131,19 +123,14 @@ await describe('EvProfiles', async () => {
           ],
         })
       )
-      try {
-        const result = loadEvProfilesFile(path, 'test')
-        assert.strictEqual(result, undefined)
-      } finally {
-        rmSync(dir, { force: true, recursive: true })
-      }
+      const result = loadEvProfilesFile(path, 'test')
+      assert.strictEqual(result, undefined)
     })
 
     await it('should load a valid file and sort curve by socPercent', () => {
-      const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
-      const path = join(dir, 'ok.json')
-      writeFileSync(
-        path,
+      const path = writeTempFile(
+        createTempDir('ev-profiles-'),
+        'ok.json',
         JSON.stringify({
           profiles: [
             {
@@ -163,23 +150,18 @@ await describe('EvProfiles', async () => {
           ],
         })
       )
-      try {
-        const result = loadEvProfilesFile(path, 'test')
-        assert.ok(result != null)
-        const curve = result.profiles[0].chargingCurve
-        assert.strictEqual(curve[0].socPercent, 0)
-        assert.strictEqual(curve[1].socPercent, 50)
-        assert.strictEqual(curve[2].socPercent, 100)
-      } finally {
-        rmSync(dir, { force: true, recursive: true })
-      }
+      const result = loadEvProfilesFile(path, 'test')
+      assert.ok(result != null)
+      const curve = result.profiles[0].chargingCurve
+      assert.strictEqual(curve[0].socPercent, 0)
+      assert.strictEqual(curve[1].socPercent, 50)
+      assert.strictEqual(curve[2].socPercent, 100)
     })
 
     await it('should swap inverted initial SoC bounds', () => {
-      const dir = mkdtempSync(join(tmpdir(), 'ev-profiles-'))
-      const path = join(dir, 'inverted.json')
-      writeFileSync(
-        path,
+      const path = writeTempFile(
+        createTempDir('ev-profiles-'),
+        'inverted.json',
         JSON.stringify({
           profiles: [
             {
@@ -194,14 +176,10 @@ await describe('EvProfiles', async () => {
           ],
         })
       )
-      try {
-        const result = loadEvProfilesFile(path, 'test')
-        assert.ok(result != null)
-        assert.strictEqual(result.profiles[0].initialSocPercentMin, 20)
-        assert.strictEqual(result.profiles[0].initialSocPercentMax, 80)
-      } finally {
-        rmSync(dir, { force: true, recursive: true })
-      }
+      const result = loadEvProfilesFile(path, 'test')
+      assert.ok(result != null)
+      assert.strictEqual(result.profiles[0].initialSocPercentMin, 20)
+      assert.strictEqual(result.profiles[0].initialSocPercentMax, 80)
     })
   })
 })
index 7741526d6cbb41010e61ab87219de171ac664ebb..1e821b29676b7ebb9d3e91cb3342a4c19be189d6 100644 (file)
@@ -6,9 +6,7 @@
 import type { AddressInfo } from 'node:net'
 
 import assert from 'node:assert/strict'
-import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
 import { request as httpRequest, type Server } from 'node:http'
-import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
@@ -16,6 +14,7 @@ import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer
 import { HttpMethod } from '../../../src/charging-station/ui-server/UIServerUtils.js'
 import { ApplicationProtocol, ConfigurationSection } from '../../../src/types/index.js'
 import { Configuration } from '../../../src/utils/index.js'
+import { cleanupTempDirs, createTempDir, writeTempFile } from '../../helpers/TempFiles.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 import { createMockBootstrap, createMockUIServerConfiguration } from './UIServerTestUtils.js'
 
@@ -181,7 +180,7 @@ await describe('UIMCPServer HTTP Integration', async () => {
     }
 
     beforeEach(() => {
-      logTmpDir = mkdtempSync(join(tmpdir(), 'mcp-log-test-'))
+      logTmpDir = createTempDir('mcp-log-test-')
       getConfigSectionCache().set(ConfigurationSection.log, {
         console: false,
         enabled: true,
@@ -195,15 +194,18 @@ await describe('UIMCPServer HTTP Integration', async () => {
 
     afterEach(() => {
       getConfigSectionCache().delete(ConfigurationSection.log)
-      rmSync(logTmpDir, { force: true, recursive: true })
+      cleanupTempDirs()
     })
 
     await it('should return log content with default date (current local date)', async () => {
       // Arrange
       const now = new Date()
       const todayDate = `${now.getFullYear().toString()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
-      const logFile = join(logTmpDir, `combined-${todayDate}.log`)
-      writeFileSync(logFile, 'info: test log line 1\ninfo: test log line 2\n')
+      writeTempFile(
+        logTmpDir,
+        `combined-${todayDate}.log`,
+        'info: test log line 1\ninfo: test log line 2\n'
+      )
 
       // Act
       const result = await callTool(testPort, 'readCombinedLog', { tail: 10 })
@@ -218,8 +220,7 @@ await describe('UIMCPServer HTTP Integration', async () => {
     await it('should return log content for explicit date parameter', async () => {
       // Arrange
       const testDate = '2020-01-01'
-      const logFile = join(logTmpDir, `combined-${testDate}.log`)
-      writeFileSync(logFile, 'info: historical log entry\n')
+      writeTempFile(logTmpDir, `combined-${testDate}.log`, 'info: historical log entry\n')
 
       // Act
       const result = await callTool(testPort, 'readCombinedLog', { date: testDate, tail: 10 })
diff --git a/tests/helpers/TempFiles.ts b/tests/helpers/TempFiles.ts
new file mode 100644 (file)
index 0000000..c205933
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * @file Temp-file helpers for tests that touch the real filesystem.
+ * @description Centralizes the `mkdtemp` + write + cleanup boilerplate that file
+ * I/O tests (id-tags cache, EV profiles, JSON storage, config hot-reload, file
+ * utils, MCP logs) would otherwise each re-implement. Dirs created via
+ * `createTempDir` are tracked and removed by `cleanupTempDirs` in `afterEach`.
+ */
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const tempDirs: string[] = []
+
+/**
+ * Creates a fresh temp dir under the OS temp root and tracks it for cleanup.
+ * @param prefix - Directory name prefix (kept per-suite for debuggability).
+ * @returns Absolute path to the created dir.
+ */
+export const createTempDir = (prefix = 'omp-test-'): string => {
+  const dir = mkdtempSync(join(tmpdir(), prefix))
+  tempDirs.push(dir)
+  return dir
+}
+
+/**
+ * Writes a file into an existing dir (typically from `createTempDir`).
+ * @param dir - Target directory.
+ * @param fileName - File name to write.
+ * @param contents - File contents.
+ * @returns Absolute path to the written file.
+ */
+export const writeTempFile = (dir: string, fileName: string, contents: string): string => {
+  const file = join(dir, fileName)
+  writeFileSync(file, contents, 'utf8')
+  return file
+}
+
+/**
+ * Removes every temp dir created by `createTempDir`. Call in `afterEach`.
+ */
+export const cleanupTempDirs = (): void => {
+  for (const dir of tempDirs.splice(0)) {
+    rmSync(dir, { force: true, recursive: true })
+  }
+}
index b988613eb5167436ccdd417e0e1b3abaaccecca1..81d8150cc516ce94ca689e4f033f350cdd1e9acc 100644 (file)
@@ -350,6 +350,17 @@ export function standardCleanup (): void {
   OCPP20VariableManager.getInstance().resetRuntimeOverrides()
 }
 
+/**
+ * Clears a `getInstance()` singleton's cached instance so the next `getInstance()`
+ * builds a fresh one. Reaches the private static `instance` field via a typed cast
+ * (test-only reflection; there is no runtime shape to validate).
+ * @param holder - The singleton class (e.g. `Bootstrap`, `IdTagsCache`).
+ */
+export const resetSingleton = (holder: unknown): void => {
+  const singleton = holder as { instance: unknown }
+  singleton.instance = null
+}
+
 /**
  * Flush all pending microtasks by yielding to the event loop.
  * setImmediate fires after all microtasks in the current event loop iteration are drained.
index 89fd279063a47adc94e1d9df2a0b69e53f06fb7b..a37f3145b5422f99520f25ab66924677f42f6750 100644 (file)
@@ -3,14 +3,14 @@
  * @description Unit tests for the JSON file performance storage backend.
  */
 import assert from 'node:assert/strict'
-import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
-import { tmpdir } from 'node:os'
+import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 import { pathToFileURL } from 'node:url'
 
 import { JsonFileStorage } from '../../../src/performance/storage/JsonFileStorage.js'
 import { logger } from '../../../src/utils/index.js'
+import { cleanupTempDirs, createTempDir } from '../../helpers/TempFiles.js'
 import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 import { buildTestStatistics } from './StorageTestHelpers.js'
 
@@ -24,7 +24,7 @@ await describe('JsonFileStorage', async () => {
   let storage: JsonFileStorage
 
   beforeEach(() => {
-    tmpDir = mkdtempSync(join(tmpdir(), 'json-file-storage-test-'))
+    tmpDir = createTempDir('json-file-storage-test-')
     dbPath = join(tmpDir, 'perf.json')
     storage = new JsonFileStorage(buildStorageUri(dbPath), LOG_PREFIX)
     storage.open()
@@ -33,7 +33,7 @@ await describe('JsonFileStorage', async () => {
   afterEach(() => {
     storage.close()
     standardCleanup()
-    rmSync(tmpDir, { force: true, recursive: true })
+    cleanupTempDirs()
   })
 
   await it('should write performance statistics atomically and leave no temp artifact behind', async () => {
index 44e9bade9529b00c17be373c5c22a7eeb71ef53f..e8073b45be53f7bc678edddd7e4ea40b5389836e 100644 (file)
@@ -3,8 +3,7 @@
  * @description Validates snapshot rollback, callback gating, lock release, and event coalescing
  */
 import assert from 'node:assert/strict'
-import { type FSWatcher, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
+import { type FSWatcher, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { afterEach, describe, it } from 'node:test'
 
@@ -14,6 +13,7 @@ import { BaseError } from '../../src/exception/index.js'
 import { ConfigurationSection } from '../../src/types/index.js'
 import { ConfigurationValidationError } from '../../src/utils/index.js'
 import { Configuration, logger } from '../../src/utils/index.js'
+import { cleanupTempDirs, createTempDir } from '../helpers/TempFiles.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import {
   buildInvalidJsonString,
@@ -35,7 +35,7 @@ interface ConfigurationInternals {
 const getInternals = (): ConfigurationInternals =>
   Configuration as unknown as ConfigurationInternals
 
-const createTempConfigDir = (): string => mkdtempSync(join(tmpdir(), 'cfg-hot-reload-'))
+const createTempConfigDir = (): string => createTempDir('cfg-hot-reload-')
 
 const writeConfigFile = (dir: string, contents: unknown): string => {
   const file = join(dir, 'config.json')
@@ -46,6 +46,7 @@ const writeConfigFile = (dir: string, contents: unknown): string => {
 await describe('Configuration hot-reload', async () => {
   afterEach(() => {
     standardCleanup()
+    cleanupTempDirs()
   })
 
   await it('should replace caches and invoke callback on a valid reload', async t => {
@@ -90,7 +91,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationSectionCache = originalCache
       internals.configurationFileReloading = originalReloading
       internals.configurationChangeCallback = originalCallback
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 
@@ -149,7 +149,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationSectionCache = originalCache
       internals.configurationFileReloading = originalReloading
       internals.configurationChangeCallback = originalCallback
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 
@@ -195,7 +194,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationSectionCache = originalCache
       internals.configurationFileReloading = originalReloading
       internals.configurationChangeCallback = originalCallback
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 
@@ -236,7 +234,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationFileReloading = originalReloading
       internals.configurationChangeCallback = originalCallback
       internals.configurationFileWatcher = originalWatcher
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 
@@ -291,7 +288,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationFileReloading = originalReloading
       internals.configurationFileReloadPending = originalPending
       internals.configurationChangeCallback = originalCallback
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 
@@ -328,7 +324,6 @@ await describe('Configuration hot-reload', async () => {
       internals.configurationSectionCache = originalCache
       internals.configurationFileReloading = originalReloading
       internals.configurationChangeCallback = originalCallback
-      rmSync(tempDir, { force: true, recursive: true })
     }
   })
 })
index bc6c6b22ae1e8b3175953c06b35738bd09e23376..5fce320502757bd6ef934268bf8fb7585a339d78 100644 (file)
@@ -6,21 +6,19 @@ import assert from 'node:assert/strict'
 import {
   existsSync,
   mkdirSync,
-  mkdtempSync,
   readdirSync,
   readFileSync,
-  rmSync,
   statSync,
   type WatchListener,
   writeFileSync,
 } from 'node:fs'
-import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import { FileType } from '../../src/types/index.js'
 import { atomicWriteFile, atomicWriteFileSync, watchJsonFile } from '../../src/utils/index.js'
 import { logger } from '../../src/utils/index.js'
+import { cleanupTempDirs, createTempDir } from '../helpers/TempFiles.js'
 import { createLoggerMocks, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 
 const LOG_PREFIX = 'FileUtils-test |'
@@ -36,12 +34,12 @@ await describe('FileUtils', async () => {
   let tmpDir: string
 
   beforeEach(() => {
-    tmpDir = mkdtempSync(join(tmpdir(), 'fileutils-test-'))
+    tmpDir = createTempDir('fileutils-test-')
   })
 
   afterEach(() => {
     standardCleanup()
-    rmSync(tmpDir, { force: true, recursive: true })
+    cleanupTempDirs()
   })
 
   await describe('watchJsonFile', async () => {