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
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/`)
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>
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)
afterEach(() => {
rmSync(testDir, { force: true, recursive: true })
- resetBootstrapSingleton()
+ resetSingleton(Bootstrap)
mock.restoreAll()
standardCleanup()
})
* 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 =>
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(
* 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
// 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',
? { 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', () => {
--- /dev/null
+/**
+ * @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)
+ })
+})
* 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.
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 = () => {
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', () => {
* 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
}
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
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 => {
* 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
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).
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)', () => {
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
})
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(
// 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
}
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/')
// 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) {
})
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/')
*/
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'
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']
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
await describe('IdTagsCache', async () => {
afterEach(() => {
standardCleanup()
- resetIdTagsCache()
+ resetSingleton(IdTagsCache)
+ cleanupTempDirs()
})
await describe('getInstance', async () => {
await it('should create new instance after reset', () => {
const instance1 = IdTagsCache.getInstance()
- resetIdTagsCache()
+ resetSingleton(IdTagsCache)
const instance2 = IdTagsCache.getInstance()
assert.notStrictEqual(instance1, instance2)
})
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', () => {
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
} 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()
afterEach(() => {
standardCleanup()
- resetSharedLRUCache()
- ;(Bootstrap as unknown as BootstrapStatic).instance = null
+ resetSingleton(SharedLRUCache)
+ resetSingleton(Bootstrap)
})
await describe('getInstance', async () => {
await it('should create new instance after reset', () => {
const instance1 = SharedLRUCache.getInstance()
- resetSharedLRUCache()
+ resetSingleton(SharedLRUCache)
const instance2 = SharedLRUCache.getInstance()
assert.notStrictEqual(instance1, instance2)
--- /dev/null
+/**
+ * @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 })
+ }
+}
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'
*/
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'
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 = {
await describe('EvProfiles', async () => {
afterEach(() => {
standardCleanup()
+ cleanupTempDirs()
})
await describe('interpolateChargingCurve', async () => {
await it('should return endpoint value at the lower boundary', () => {
})
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: [
{
],
})
)
- 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: [
{
],
})
)
- 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: [
{
],
})
)
- 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)
})
})
})
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'
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'
}
beforeEach(() => {
- logTmpDir = mkdtempSync(join(tmpdir(), 'mcp-log-test-'))
+ logTmpDir = createTempDir('mcp-log-test-')
getConfigSectionCache().set(ConfigurationSection.log, {
console: false,
enabled: true,
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 })
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 })
--- /dev/null
+/**
+ * @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 })
+ }
+}
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.
* @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'
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()
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 () => {
* @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'
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,
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')
await describe('Configuration hot-reload', async () => {
afterEach(() => {
standardCleanup()
+ cleanupTempDirs()
})
await it('should replace caches and invoke callback on a valid reload', async t => {
internals.configurationSectionCache = originalCache
internals.configurationFileReloading = originalReloading
internals.configurationChangeCallback = originalCallback
- rmSync(tempDir, { force: true, recursive: true })
}
})
internals.configurationSectionCache = originalCache
internals.configurationFileReloading = originalReloading
internals.configurationChangeCallback = originalCallback
- rmSync(tempDir, { force: true, recursive: true })
}
})
internals.configurationSectionCache = originalCache
internals.configurationFileReloading = originalReloading
internals.configurationChangeCallback = originalCallback
- rmSync(tempDir, { force: true, recursive: true })
}
})
internals.configurationFileReloading = originalReloading
internals.configurationChangeCallback = originalCallback
internals.configurationFileWatcher = originalWatcher
- rmSync(tempDir, { force: true, recursive: true })
}
})
internals.configurationFileReloading = originalReloading
internals.configurationFileReloadPending = originalPending
internals.configurationChangeCallback = originalCallback
- rmSync(tempDir, { force: true, recursive: true })
}
})
internals.configurationSectionCache = originalCache
internals.configurationFileReloading = originalReloading
internals.configurationChangeCallback = originalCallback
- rmSync(tempDir, { force: true, recursive: true })
}
})
})
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 |'
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 () => {