]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor: convention alignment round 2 — canonical defaults, helpers, dead exports...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 6 Jul 2026 11:52:59 +0000 (13:52 +0200)
committerGitHub <noreply@github.com>
Mon, 6 Jul 2026 11:52:59 +0000 (13:52 +0200)
* refactor: convention alignment round 2 — canonical defaults, helpers, dead exports, tests

Follow-up to #1950/#1952/#1953/#1954. Post-audit factorization; behavior-preserving.

Canonical defaults promoted to Constants.ts:
- UNIT_DIVIDER_CENTI/DECI (100/10); adopt UNIT_DIVIDER_KILO at 3 kW/kWh sites
- DEFAULT_RECONNECT_JITTER_PERCENT, SOC_MAXIMUM_PERCENT
- DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS
- DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS
- MS_PER_DAY, SECONDS_PER_DAY (align with existing MS_PER_HOUR)

Canonical defaults promoted to OCPP20Constants.ts (OCPP-variable-read fallbacks):
- DEFAULT_RETRY_BACKOFF_{WAIT_MINIMUM,RANDOM_RANGE}_SECONDS
- DEFAULT_RETRY_BACKOFF_REPEAT_TIMES
- DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS

Module-scope constants:
- WS_DEFLATE_{CONCURRENCY_LIMIT,SERVER_MAX_WINDOW_BITS,ZLIB_*} in UIWebSocketServer
- STATISTICS_PERCENTILE in PerformanceStatistics
- AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS, AUTH_CACHE_MIN_ENTRIES,
  AUTH_TIMEOUT_{MIN,MAX}_SECONDS in ConfigValidator
- JITTER_PERCENT local to src/worker/ (module standalone; no cross-dep)
- SIGINT/SIGTERM_EXIT_CODE, MISSING_VALUE_PLACEHOLDER,
  TEMPLATE_NAME_SUFFIX, TRUNCATED_HASH_ID_LENGTH in ui/cli output
- SKIN_ERROR_RELOAD_COUNT_KEY in ui/web core

Helper adoption:
- millisecondsToSeconds (date-fns) at AbstractUIServer / InMemoryAuthCache / test
- isNotEmptyString in TemplateValidation
- WebSocketReadyState enum (ui-common) at CLI wsIcon/renderers and web stationStatus
- OCPP16ChargePointStatus / OCPP20ConnectorStatusEnumType enum keys instead
  of string literals in CLI STATUS_ABBREVIATIONS and web CONNECTOR_STATUS_VARIANT
- nonEmptyStringOrUndefined shared helper (ui/web/src/shared/utils) adopted
  by 3 composables + skin error extractor
- Extracted OCPPRequestService.logRequestHandlerError() consumed by OCPP16/20
  RequestService (byte-identical catch shell)

Barrel/module hygiene:
- Close barrel gap: meter-values/index.ts exposes disposeCoherentSessionRuntime
  (JSDoc already advertised it)
- UIServerAccessPolicy imports UI_SERVER_ACCESS_POLICY_DEFAULTS via utils/index
  barrel (drop deep import)
- ui/web skins route 8 imports through @/shared/utils/index barrel
- UIServiceWorkerBroadcastChannel type-only imports AbstractUIService via
  ui-server/index barrel (safe by type erasure; comment documents the constraint)

Dead barrel exports pruned (0 external consumers):
- src/utils: FieldError, applyConfigurationMigration, coerceConfigurationVersion,
  queueMicrotaskErrorThrowing (tests updated to import from source)
- charging-station/meter-values: ChargingCurvePoint
- ui-server/mcp: MCPToolSchema
- ocpp/auth: AuthStats, CacheStats, CertificateAuthProvider, CertificateInfo
- ocpp/2.0/__testable__: SendMessageFn, TestableRequestServiceOptions/Result
  re-exports; TestableOCPP20IncomingRequestService downgraded to module-private;
  TestableOCPP20VariableManager type re-export dropped

Anchor-comment reasons added on 3 eslint-disable directives (Utils /
StatisticUtils / UIServerFactory).

Test constants:
- Heartbeat interval sites use Constants.DEFAULT_HEARTBEAT_INTERVAL_MS /
  secondsToMilliseconds
- New tests/utils/TestNetworkConstants.ts: TEST_SUPERVISION_URL derived from
  Constants.DEFAULT_UI_SERVER_{HOST,PORT}; adopted at MessageChannelUtils.test
  + StorageTestHelpers + ConfigurationFixtures
- Rate-limit / mock-timers-tick 60000 literals replaced by minutesToMilliseconds(1)
  in UIServerSecurity / OCPP20CertSigningRetryManager / SignedMeterValues tests

Deliberately not applied (documented rationale):
- src/worker/WorkerAbstract isNotEmptyString adoption: src/worker/ is a
  standalone module with zero cross-module dependencies; retracted rather
  than adding an import to ../utils
- AbstractUIService → broadcast-channel/index value-import via barrel:
  empirically amplifies circular dependencies 62 → 71 because the barrel also
  re-exports ChargingStationWorkerBroadcastChannel whose transitive graph
  closes a runtime cycle back through ui-server/index; deep import kept

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)

* [autofix.ci] apply automated fixes

* refactor: address self-review findings — DRY constant refs, helper adoption, naming

Fixes 9 findings surfaced by fan-out self-review of commit a25f4ba9.
Behavior-preserving; no public API change.

Findings addressed:

- H1 (DRY, Constants.ts) — `DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS` and
  `MS_PER_DAY` both held the literal `86_400_000` (same value, same commit).
  Class-static forward-reference is illegal in TypeScript (TS2729:
  "Property 'B' is used before its initialization"), so the shared literal is
  hoisted to module-scope `DAY_IN_MS` / `DAY_IN_SECONDS` helpers before the
  class and referenced from both derived defaults and canonical time-unit
  constants. Single source of truth restored.

- H2 (DRY, ConfigValidator.ts) — `AUTH_CACHE_LIFETIME_MAX_SECONDS = 86_400`
  duplicated `Constants.SECONDS_PER_DAY = 86_400`. Fixed cross-file by
  importing `Constants` (already reachable via `../../../../utils/index.js`)
  and referencing `Constants.SECONDS_PER_DAY`.

- M1 (helper adoption, ui/cli format.ts:102) — `fuzzyTime` still used
  `diff / 1000` while the same PR adopted `millisecondsToSeconds` from
  `date-fns` at 4 other sites. Replaced by `millisecondsToSeconds(diff)`
  for consistency.

- L1 (naming coherence, ConfigValidator.ts) — Renamed
  `AUTH_CACHE_LIFETIME_{MIN,MAX}_SECONDS` to
  `AUTH_CACHE_TTL_{MIN,MAX}_SECONDS` to align with the pre-existing
  `DEFAULT_AUTH_CACHE_TTL_SECONDS` naming family. Two words for one concept
  eliminated within the auth module.

- L2 (naming coherence, ui/cli output) — Renamed cli
  `MISSING_VALUE_PLACEHOLDER` to `EMPTY_VALUE_PLACEHOLDER` to align with
  ui/web's pre-existing `EMPTY_VALUE_PLACEHOLDER`. Values remain
  medium-specific (`'–'` cli / `'Ø'` web), only the semantic role name is
  shared. 6 sites updated.

- L3 (stranded export, format.ts) — `TRUNCATED_HASH_ID_LENGTH` had zero
  external consumers (only the in-file default arg on `truncateId`).
  Dropped `export` keyword.

- L4 (stranded export, format.ts) — `TEMPLATE_NAME_SUFFIX` had zero external
  consumers (only the in-file `stripTemplateSuffix` helper). Dropped
  `export` keyword; the helper itself keeps `export` (2 external consumers).

- L5 (documented duplication, WorkerUtils.ts) — JSDoc on `JITTER_PERCENT`
  now explicitly cross-references `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
  and documents the maintenance invariant. The two constants must stay
  synchronized manually because `src/worker/` is a standalone module (no
  cross-module value imports).

- I1 (anchor accuracy, StatisticUtils.ts:52) — The
  `no-unnecessary-condition` disable reason is reworded from a plausible-
  but-imprecise "JSON.parse violation" framing to the actual root cause:
  `baseIndex+1` can equal `sortedDataSet.length`, and TypeScript indexes
  as `number` (no `noUncheckedIndexedAccess`), so the runtime `!= null`
  guard is genuinely needed for out-of-bounds protection.

Empirical retraction verifications:

- B1-5 (cycle amplification 62 → 71) — Re-verified by applying only the
  retracted change on top of `daad8a9d` and running `pnpm circular-deps`:
  62 baseline, 71 after B1-5, 62 after revert. Retraction rationale holds.

- A1-17 (`src/worker/` standalone) — Re-verified by `grep -rE
  "^import.*\.\./" src/worker/`: zero cross-module value imports; the new
  L5 JSDoc documents the exception rationale in-source.

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)
- pnpm test: 2955/2961 pass (6 skipped pre-existing; 0 fail)

* docs: fix JSDoc accuracy from review v3 findings (G1, A1, A2)

Doc-only follow-up to commit 6ba8503c. Behavior unchanged.

- G1 (JITTER doc accuracy) — Reworded both `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`
  and worker-local `JITTER_PERCENT` JSDoc from the empirically false claim
  "symmetric ±20 %" to "bounds the jitter within ±20 %". The `randomizeDelay`
  algorithm couples sign and magnitude through a single uniform draw, so with
  `random ∈ [0, 1)` and `sign = random < 0.5 ? -1 : +1` the output range is
  `(0.9·delay, delay] ∪ [1.1·delay, 1.2·delay)` — asymmetric, with a gap at
  `[delay, 1.1·delay)`. The new wording accurately caps the magnitude without
  overclaiming symmetry; the WorkerUtils docstring adds an explicit distribution
  note pointing to `randomizeDelay`. Algorithm itself pre-dates the PR and is
  behavior-preserved.

- A1 (InMemoryAuthCache @param defaults) — Three JSDoc `@param default:` fields
  were pointing at raw literals (3600, 86400000, 60000) instead of the promoted
  Constants members. Aligned with the pattern already used for `maxEntries`
  (which cites `Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES`): now all six
  `@param default:` values reference the canonical `Constants.*` symbol.

- A2 (DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS wording) — JSDoc used the phrase
  "distinct from local `_TTL_SECONDS = 3600`" where `_TTL_SECONDS` is not an
  actual symbol. Replaced by explicit sibling reference
  `DEFAULT_AUTH_CACHE_TTL_SECONDS (3600, local cache default)`.

Quality gates:
- pnpm typecheck: clean (root + ui-common + cli + web)
- pnpm lint: 0 errors (1 pre-existing cspell warning unchanged)
- pnpm circular-deps: 62 (baseline unchanged)

* fix: web-test regression from A2-5 + review v4 findings + documented quality gates

Restores web test suite to full green after documented quality gates surfaced
regressions the prior review rounds missed. Behavior-preserving vs the intent
of round-1 refactor.

Regressions fixed (QG-1/QG-2/QG-3 — blockers surfaced by `pnpm --filter web
test:coverage`):

- `stationStatus.ts` — round-1 finding A2-5 replaced `switch (status?.toLowerCase())`
  by a Record keyed by `OCPP16ChargePointStatus.*` / `OCPP20ConnectorStatusEnumType.*`
  enum values. That change (a) dropped the case-insensitive contract asserted by
  `stationStatus.test.ts:74`, and (b) introduced a module-scope runtime access
  to `ui-common` enum values, which fails at module init when the containing
  test suite mocks `ui-common` (2 file-level FAILs in `useAddStationsForm.test.ts`
  and `useStartTxForm.test.ts`).

  Design chosen (Option F in review v4 report): keep the Record structure but
  key it by lowercase string literals and normalize input via `.toLowerCase()`
  on lookup. Retains the refactor benefit (Record > switch for O(1) lookup,
  clearer add/remove semantics), restores case-insensitive input, removes the
  module-scope enum dependency, and preserves behaviour for the 9 status values
  the switch covered. Enum imports dropped since no other value site remains.

  Web test suite: 31 files / 532 tests pass (previously 3 files failed / 1
  test failed).

Review v4 low-severity findings:

- v4-A1 `Constants.ts:77` — JSDoc "bounds within ±20 %" for
  `DEFAULT_RECONNECT_JITTER_PERCENT` implied a bidirectional range, but algebra
  of the sole consumer `computeExponentialBackOffDelay` (`jitter = delay ×
  jitterPercent × secureRandom()` with `secureRandom() ∈ [0, 1)`) yields
  `jitter ∈ [0, +20 %)` strictly non-negative. Reworded to describe the
  additive-uniform-draw semantic and cross-reference the two distinct consumer
  distributions.
- v4-A2 `WorkerUtils.ts:12` — "shared jitter policy" overclaimed behavioural
  parity between the two consumers (positive-only vs asymmetric with a
  probability-zero gap). Softened to "shared jitter magnitude cap".
- v4-B1 `ConfigurationFixtures.ts:67` — `uiServer.options` still hardcoded
  `host: 'localhost', port: 8080` after the sibling `supervisionUrls` field on
  the same object literal was migrated to `TEST_SUPERVISION_URL`. Extended
  `tests/utils/TestNetworkConstants.ts` to also export `TEST_UI_SERVER_HOST`
  and `TEST_UI_SERVER_PORT` (re-exports of `Constants.DEFAULT_UI_SERVER_HOST` /
  `Constants.DEFAULT_UI_SERVER_PORT`).
- v4-B4 `ConfigurationMigrations.test.ts:130,141` — string literals
  `'ws://localhost:8080'` in a file the PR already touched (barrel-to-source
  import migration) but where the URL literals were skipped in round 2.
  Migrated to `TEST_SUPERVISION_URL`.

Lint policy alignment (user directive "aucun warning"):

- `cspell.config.yaml` — added `subclassing` (used in
  `src/charging-station/ui-server/index.ts:10`, pre-existing since #1954). Root
  lint now reports 0 errors AND 0 warnings.

Deferred (documented in review v4 report, out of this PR's scope):

- v4-B2 `ConfigValidator.ts` MIN/MAX bounds harmonization (already tracked as M2)
- v4-B3 asymmetry between `1.6/__testable__/index.ts` (exports
  `TestableOCPP16IncomingRequestService`) and `2.0/__testable__/index.ts`
  (interface demoted to non-exported in round 2). Requires either restoring
  the 2.0 export or migrating 1.6 consumers to `ReturnType<typeof …>` —
  larger touch, kept for a dedicated follow-up.
- Pre-existing gap/asymmetry in `randomizeDelay`'s distribution
  (`(−10 %, 0] ∪ [+10 %, +20 %)` — magnitude and sign both derive from a single
  uniform draw). Documented in-source, not fixed by this refactor.

Quality gates (documented per `.serena/memories/task_completion_checklist`):

- Root: `pnpm format` / `typecheck` / `lint` (0 err, 0 warn) / `build` / `test`
  (2955 pass / 6 skipped / 0 fail)
- CLI: `pnpm format` / `typecheck` / `lint` / `build` / `test` (152/152 pass)
- Web: `pnpm format` / `typecheck` / `lint` / `build` / `test:coverage`
  (532/532 pass, 31 test files)
- Circular deps: 62 (baseline unchanged)

* docs: tighten prose in 5 comments — state-describing, not narrative

Removes historical/imperative framing ("kept as", "hoisted to", "if a future
edit converts...", "must not access... because") in favour of concise
statements of what is.

- `Constants.ts:9` module-scope helper: 3→2 lines
- `Constants.ts:80` DEFAULT_RECONNECT_JITTER_PERCENT JSDoc: 6→4 lines
- `WorkerUtils.ts:1` JITTER_PERCENT JSDoc: 9→5 lines
- `UIServiceWorkerBroadcastChannel.ts:1` type-only barrier: 5→3 lines
- `stationStatus.ts:64` CONNECTOR_STATUS_VARIANT: reworded, same length

Net: -9 lines. All invariants preserved (TS2729, cross-module boundary,
runtime-cycle prevention, no-module-scope-enum). Behavior unchanged.

Quality gates:
- Root: format / typecheck / lint (0 err, 0 warn) / build / test (2955 pass)
- CLI: 5/5 clean (152/152 pass)
- Web: 5/5 clean (532/532 pass, test:coverage)

* fix: address review v5 findings — imperative comment, dead exports, module-scope enum

Applies the 3 actionable findings from review round 5. Behavior-preserving.

- v5-A1 `SkinLoadError.vue:28-32` — Reworded `resetToDefault` JSDoc from 4-line
  imperative `NOTE: ...should clear the reload-count sessionStorage key...`
  to 1-line state-describing `Counter is reset by useSkin.switchSkin on
  successful load.` Missed by the round-4 comment tightening sweep.

- v5-B1 `TestNetworkConstants.ts` / `ConfigurationFixtures.ts` — Retired
  `TEST_UI_SERVER_HOST` / `TEST_UI_SERVER_PORT` exports (1 external consumer
  each, both at the same `ConfigurationFixtures.ts:71` expression — criterion 10
  requires ≥2 consumers). Inlined `Constants.DEFAULT_UI_SERVER_HOST` /
  `Constants.DEFAULT_UI_SERVER_PORT` at the single call site.
  `TEST_SUPERVISION_URL` remains exported (5 consumers).

- v5-B2 `ui/web/src/shared/utils/stationStatus.ts` — Removed the module-scope
  value import `import { WebSocketReadyState } from 'ui-common'` and switched
  `getWebSocketStateVariant` to 4 module-local numeric constants
  `WS_STATE_{CONNECTING,OPEN,CLOSING,CLOSED} = 0/1/2/3`. Eliminates the same
  ui-common runtime module-scope dependency that the round-4 fix removed for
  `CONNECTOR_STATUS_VARIANT`. No behavior change (values match WHATWG WebSocket
  readyState spec).

Quality gates:
- Root: 5/5 clean, 2942/2948 pass
- CLI: 5/5 clean, 152/152 pass
- Web: 5/5 clean, 532/532 pass (test:coverage)
- Circular deps: 62 (baseline)

* docs: drop narrative comments in stationStatus.ts

Removes two comment blocks that narrated rationale ("mirror of...",
"module-local to keep...", "test suites mock...") instead of describing
what is. The `WS_STATE_*` constants and lowercase Record keys are
self-documenting.

- `stationStatus.ts:62-66` — 4-line rationale block removed; `cspell:ignore`
  directive retained
- `stationStatus.ts:83-85` — 3-line rationale block removed

Quality gates: root/web 5/5 clean, 532/532 pass.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
61 files changed:
cspell.config.yaml
src/charging-station/ChargingStation.ts
src/charging-station/CoherentMeterValuesManager.ts
src/charging-station/HelpersConfig.ts
src/charging-station/TemplateValidation.ts
src/charging-station/broadcast-channel/UIServiceWorkerBroadcastChannel.ts
src/charging-station/meter-values/index.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.ts
src/charging-station/ocpp/2.0/OCPP20Constants.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/charging-station/ocpp/OCPPRequestService.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts
src/charging-station/ocpp/auth/cache/InMemoryAuthCache.ts
src/charging-station/ocpp/auth/index.ts
src/charging-station/ocpp/auth/strategies/RemoteAuthStrategy.ts
src/charging-station/ocpp/auth/utils/ConfigValidator.ts
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIServerAccessPolicy.ts
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/charging-station/ui-server/mcp/index.ts
src/performance/PerformanceStatistics.ts
src/utils/Constants.ts
src/utils/StatisticUtils.ts
src/utils/Utils.ts
src/utils/index.ts
src/worker/WorkerUtils.ts
tests/charging-station/ChargingStation-Configuration.test.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts
tests/charging-station/ocpp/2.0/OCPP20CertSigningRetryManager.test.ts
tests/charging-station/ui-server/UIServerSecurity.test.ts
tests/performance/storage/StorageTestHelpers.ts
tests/utils/ConfigurationMigrations.test.ts
tests/utils/MessageChannelUtils.test.ts
tests/utils/TestNetworkConstants.ts [new file with mode: 0644]
tests/utils/Utils.test.ts
tests/utils/helpers/ConfigurationFixtures.ts
ui/cli/src/client/lifecycle.ts
ui/cli/src/output/format.ts
ui/cli/src/output/renderers.ts
ui/web/src/core/Constants.ts
ui/web/src/core/index.ts
ui/web/src/shared/components/SkinLoadError.vue
ui/web/src/shared/composables/useAddStationsForm.ts
ui/web/src/shared/composables/useSkin.ts
ui/web/src/shared/composables/useStartTxForm.ts
ui/web/src/shared/utils/index.ts
ui/web/src/shared/utils/nonEmptyString.ts [new file with mode: 0644]
ui/web/src/shared/utils/stationStatus.ts
ui/web/src/skins/classic/ClassicLayout.vue
ui/web/src/skins/classic/components/charging-stations/CSData.vue
ui/web/src/skins/modern/components/ConnectorRow.vue
ui/web/src/skins/modern/components/SimulatorBar.vue
ui/web/src/skins/modern/components/StationCard.vue
ui/web/src/skins/modern/components/dialogs/SetSupervisionUrlDialog.vue
ui/web/src/skins/modern/utils/errors.ts

index d90fa86bdcb854bb5084d87ab48e8d91fff8b0a4..02662796237792487de8ad3d7a7c1c203faa148d 100644 (file)
@@ -37,6 +37,7 @@ words:
   - entrancy
   - recurrency
   - shutdowning
+  - subclassing
   - VCAP
   - workerd
   - yxxx
index fe4fdcfe94ae11895627c9921a4ff469dfed77c6..f32f9d0c44a631a6c157d63dcd6fcc6ed0bddb66 100644 (file)
@@ -1607,7 +1607,7 @@ export class ChargingStation extends EventEmitter {
     return this.stationInfo?.reconnectExponentialDelay === true
       ? computeExponentialBackOffDelay({
         baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
-        jitterPercent: 0.2,
+        jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT,
         retryNumber: this.wsConnectionRetryCount,
       })
       : secondsToMilliseconds(Constants.DEFAULT_WS_RECONNECT_DELAY_SECONDS)
@@ -2787,7 +2787,7 @@ export class ChargingStation extends EventEmitter {
         sleep(
           computeExponentialBackOffDelay({
             baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
-            jitterPercent: 0.2,
+            jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT,
             retryNumber: messageIdx ?? 0,
           })
         )
index 5cdcd8ec37099cc2679f66bdce50879b39a32cfb..050e0e258576bd53024db55d8654d02dd83098fd 100644 (file)
@@ -15,10 +15,10 @@ import type { ChargingStation } from './ChargingStation.js'
 import { BaseError } from '../exception/index.js'
 import { isEmpty, logger } from '../utils/index.js'
 import { getEvProfilesFile } from './Helpers.js'
-import { disposeCoherentSessionRuntime } from './meter-values/CoherentSampleComputer.js'
 import {
   type CoherentSession,
   createCoherentSession,
+  disposeCoherentSessionRuntime,
   type EvProfilesFile,
   loadEvProfilesFile,
   resolveRootSeed,
index 12a4d13ba019aa79e7ae27a8aa7f02ee293a4de3..cc43f9cc2ef8979cd603220485aeaf8d5bccd7c7 100644 (file)
@@ -307,13 +307,13 @@ export const getAmperageLimitationUnitDivider = (stationInfo: ChargingStationInf
   let unitDivider = 1
   switch (stationInfo.amperageLimitationUnit) {
     case AmpereUnits.CENTI_AMPERE:
-      unitDivider = 100
+      unitDivider = Constants.UNIT_DIVIDER_CENTI
       break
     case AmpereUnits.DECI_AMPERE:
-      unitDivider = 10
+      unitDivider = Constants.UNIT_DIVIDER_DECI
       break
     case AmpereUnits.MILLI_AMPERE:
-      unitDivider = 1000
+      unitDivider = Constants.UNIT_DIVIDER_KILO
       break
   }
   return unitDivider
index 15ac7dbb6f1f9c314a2234ce9a3c26341b3dcde8..72d97073c1470af47493f8bbb6f685e0543d9e52 100644 (file)
@@ -101,10 +101,7 @@ function transformTemplate (
   validated: Record<string, unknown>,
   filePath: string
 ): ChargingStationTemplate {
-  if (
-    validated.idTagsFile == null ||
-    (typeof validated.idTagsFile === 'string' && !isNotEmptyString(validated.idTagsFile))
-  ) {
+  if (!isNotEmptyString(validated.idTagsFile)) {
     logger.warn(
       `${moduleName}.transformTemplate: Missing id tags file in template file ${filePath}. That can lead to issues with the Automatic Transaction Generator`
     )
index 36a13f674929a9440b25d1926a4a5e1bb3c42260..5460434cdd790ff1e677cbd413ec02fb395667af 100644 (file)
@@ -1,4 +1,7 @@
-import type { AbstractUIService } from '../ui-server/ui-services/AbstractUIService.js'
+// Type-only edge, erased at compile time. A value import through this barrel
+// would pull `UIHttpServer` / `UIMCPServer` / `UIServiceFactory` transitively
+// and close a runtime cycle.
+import type { AbstractUIService } from '../ui-server/index.js'
 
 import {
   type BroadcastChannelResponse,
index 4e8e91d847cd6afff9e91209a4d0902b98c2eeb6..3f4623b7f70948bce25ef803a7d54ece1eb44f93 100644 (file)
 
 export { buildCoherentMeterValue } from './CoherentMeterValueBuilder.js'
 export type { BuildVersionedSampledValue } from './CoherentMeterValueBuilder.js'
+export { disposeCoherentSessionRuntime } from './CoherentSampleComputer.js'
 export { createCoherentSession, isCoherentModeActive, resolveRootSeed } from './CoherentSession.js'
 export { loadEvProfilesFile } from './EvProfiles.js'
-export type {
-  ChargingCurvePoint,
-  CoherentSession,
-  EvProfile,
-  EvProfilesFile,
-  ICoherentContext,
-} from './types.js'
+export type { CoherentSession, EvProfile, EvProfilesFile, ICoherentContext } from './types.js'
index 43e34113ae9ae2a157163f399a0b6995e9f4e06e..6b39f17c970e2616a4c4f150ebcb92f81bf0047f 100644 (file)
@@ -129,10 +129,7 @@ export class OCPP16RequestService extends OCPPRequestService {
         )
         return response
       } catch (error) {
-        logger.error(
-          `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`,
-          error
-        )
+        this.logRequestHandlerError(chargingStation, moduleName, commandName, error)
         throw error
       }
     }
index 303b9daf69a1cbe313adca18b90e4418cfbe5afb..52011a7f1162e98048585fc50ad3a40cad565be1 100644 (file)
@@ -52,6 +52,7 @@ import {
 } from '../../../types/index.js'
 import {
   clampToSafeTimerValue,
+  Constants,
   convertToBoolean,
   convertToDate,
   convertToInt,
@@ -223,7 +224,9 @@ export class OCPP16ServiceUtils {
     const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
     if (sampledValueTemplate != null) {
       const unitDivider =
-        sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
+        sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR
+          ? Constants.UNIT_DIVIDER_KILO
+          : 1
       meterValue.sampledValue.push(
         buildOCPP16SampledValue(
           sampledValueTemplate,
@@ -294,7 +297,10 @@ export class OCPP16ServiceUtils {
         `Missing MeterValues for default measurand '${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}`
       )
     }
-    const unitDivider = sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1
+    const unitDivider =
+      sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR
+        ? Constants.UNIT_DIVIDER_KILO
+        : 1
     const meterValue = buildEmptyMeterValue() as OCPP16MeterValue
     meterValue.sampledValue.push(
       buildOCPP16SampledValue(
index 3a4397c2837d750ab0184fd2ae74e289950f0495..71549a5d257e7691c149af0e4734285adf3519df 100644 (file)
@@ -10,6 +10,7 @@ import {
   OCPP20RequestCommand,
 } from '../../../types/index.js'
 import { computeExponentialBackOffDelay, logger } from '../../../utils/index.js'
+import { OCPP20Constants } from './OCPP20Constants.js'
 import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
 
 const moduleName = 'OCPP20CertSigningRetryManager'
@@ -90,7 +91,7 @@ export class OCPP20CertSigningRetryManager {
           this.chargingStation,
           OCPP20ComponentName.SecurityCtrlr,
           OCPP20OptionalVariableName.CertSigningWaitMinimum,
-          60
+          OCPP20Constants.DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS
         )
     )
     const delayMs = computeExponentialBackOffDelay({
index 48e8fed2359fb984dc85fe82aea9d3b740ae4c6d..ce5af58e92da3d722be12e3c688f02f88a11034a 100644 (file)
@@ -140,8 +140,16 @@ export class OCPP20Constants extends OCPPConstants {
     // { from: OCPP20ConnectorStatusEnumType.Faulted, to: OCPP20ConnectorStatusEnumType.Faulted }
   ])
 
+  static readonly DEFAULT_CERT_SIGNING_WAIT_MINIMUM_SECONDS = 60
+
   static readonly DEFAULT_CONNECTION_URL = 'ws://localhost'
 
+  static readonly DEFAULT_RETRY_BACKOFF_RANDOM_RANGE_SECONDS = 10
+
+  static readonly DEFAULT_RETRY_BACKOFF_REPEAT_TIMES = 5
+
+  static readonly DEFAULT_RETRY_BACKOFF_WAIT_MINIMUM_SECONDS = 30
+
   static readonly FIRMWARE_INSTALL_DELAY_MS = 5000
   static readonly FIRMWARE_STATUS_DELAY_MS = 2000
   static readonly FIRMWARE_VERIFY_DELAY_MS = 500
index 33e0655574da8c6b5cb40d18879d241d26ccb128..381f75b462265b590219cb0fc5659f7e8d3079c7 100644 (file)
@@ -129,10 +129,7 @@ export class OCPP20RequestService extends OCPPRequestService {
         )
         return response
       } catch (error) {
-        logger.error(
-          `${chargingStation.logPrefix()} ${moduleName}.requestHandler: Error processing '${commandName}' request:`,
-          error
-        )
+        this.logRequestHandlerError(chargingStation, moduleName, commandName, error)
         throw error
       }
     }
index 4eb45de6bf4913f8624a5e6f89bfe3b743e70b0f..cedcd25bee3e1f3f624aa22413fb80477f36bd6b 100644 (file)
@@ -65,6 +65,7 @@ import {
   createPayloadConfigs,
   PayloadValidatorOptions,
 } from '../OCPPServiceUtils.js'
+import { OCPP20Constants } from './OCPP20Constants.js'
 import { mapStopReasonToOCPP20 } from './OCPP20RequestBuilders.js'
 import { OCPP20VariableManager } from './OCPP20VariableManager.js'
 
@@ -236,19 +237,19 @@ export class OCPP20ServiceUtils {
       chargingStation,
       OCPP20ComponentName.OCPPCommCtrlr,
       OCPP20OptionalVariableName.RetryBackOffWaitMinimum,
-      30
+      OCPP20Constants.DEFAULT_RETRY_BACKOFF_WAIT_MINIMUM_SECONDS
     )
     const randomRange = OCPP20ServiceUtils.readVariableAsInteger(
       chargingStation,
       OCPP20ComponentName.OCPPCommCtrlr,
       OCPP20OptionalVariableName.RetryBackOffRandomRange,
-      10
+      OCPP20Constants.DEFAULT_RETRY_BACKOFF_RANDOM_RANGE_SECONDS
     )
     const repeatTimes = OCPP20ServiceUtils.readVariableAsInteger(
       chargingStation,
       OCPP20ComponentName.OCPPCommCtrlr,
       OCPP20OptionalVariableName.RetryBackOffRepeatTimes,
-      5
+      OCPP20Constants.DEFAULT_RETRY_BACKOFF_REPEAT_TIMES
     )
     return computeExponentialBackOffDelay({
       baseDelayMs: secondsToMilliseconds(waitMinimum),
index 40629a6f3ef4f1b64168e1d8597712e5ba773d72..73480b98b7079b110e96d48b38b65fdb4297e7f5 100644 (file)
@@ -69,7 +69,7 @@ import type { OCPP20IncomingRequestService } from '../OCPP20IncomingRequestServi
  * Interface exposing private handler methods of OCPP20IncomingRequestService for testing.
  * Each method signature matches the corresponding private method in the service class.
  */
-export interface TestableOCPP20IncomingRequestService {
+interface TestableOCPP20IncomingRequestService {
   /**
    * Builds report data for the device model report.
    * Used internally by handleRequestGetBaseReport.
@@ -313,11 +313,8 @@ export function createTestableIncomingRequestService (
 
 export {
   createTestableRequestService,
-  type SendMessageFn,
   type SendMessageMock,
   type TestableOCPP20RequestService,
-  type TestableRequestServiceOptions,
-  type TestableRequestServiceResult,
 } from './OCPP20RequestServiceTestable.js'
 
 export {
@@ -325,7 +322,4 @@ export {
   type TestableOCPP20ResponseService,
 } from './OCPP20ResponseServiceTestable.js'
 
-export {
-  createTestableVariableManager,
-  type TestableOCPP20VariableManager,
-} from './OCPP20VariableManagerTestable.js'
+export { createTestableVariableManager } from './OCPP20VariableManagerTestable.js'
index 46b4cf2a75038a6252a79afc300af4474255f385..4d30f9a7c78eab3b852537bb330b6cdb7fa0471d 100644 (file)
@@ -139,6 +139,18 @@ export abstract class OCPPRequestService {
     }
   }
 
+  protected logRequestHandlerError (
+    chargingStation: ChargingStation,
+    subclassModuleName: string,
+    commandName: RequestCommand,
+    error: unknown
+  ): void {
+    logger.error(
+      `${chargingStation.logPrefix()} ${subclassModuleName}.requestHandler: Error processing '${commandName}' request:`,
+      error
+    )
+  }
+
   protected async sendMessage (
     chargingStation: ChargingStation,
     messageId: string,
index fc37e9b5ba48511c5b425838b0865bb9e9efbb55..d93196d1dba91dcdaf6c3abbad473ef5314e1412 100644 (file)
@@ -91,8 +91,6 @@ import {
 
 const moduleName = 'OCPPServiceUtils'
 
-const SOC_MAXIMUM_VALUE = 100
-
 const isOCPP20FlagEnabled = (
   chargingStation: ChargingStation,
   component: OCPP20ComponentName,
@@ -262,7 +260,7 @@ const buildSocMeasurandValue = (
     return null
   }
 
-  const socMaximumValue = SOC_MAXIMUM_VALUE
+  const socMaximumValue = Constants.SOC_MAXIMUM_PERCENT
   const socMinimumValue = socSampledValueTemplate.minimumValue ?? 0
   const socSampledValueTemplateValue = isNotEmptyString(socSampledValueTemplate.value)
     ? getRandomFloatFluctuatedRounded(
@@ -1210,7 +1208,7 @@ export const buildMeterValue = (
       connectorId,
       convertToInt(socSampledValue.value),
       socMeasurand.template.minimumValue ?? 0,
-      SOC_MAXIMUM_VALUE,
+      Constants.SOC_MAXIMUM_PERCENT,
       socSampledValue.measurand,
       debug
     )
index d416f0123b074894e71b34c7702e68ec75560d06..cb4031fbc0ff84e1cace9902e3b5f24268f21155 100644 (file)
@@ -7,7 +7,7 @@ import {
   MeterValueUnit,
   SigningMethodEnumType,
 } from '../../types/index.js'
-import { roundTo } from '../../utils/index.js'
+import { Constants, roundTo } from '../../utils/index.js'
 
 export interface SignedMeterData extends JsonObject {
   encodingMethod: EncodingMethodEnumType
@@ -53,7 +53,7 @@ export const generateSignedMeterData = (
   const meterValueKwh =
     params.meterValueUnit === MeterValueUnit.KILO_WATT_HOUR
       ? roundTo(params.meterValue, 3)
-      : roundTo(params.meterValue / 1000, 3)
+      : roundTo(params.meterValue / Constants.UNIT_DIVIDER_KILO, 3)
 
   const ocmfPayload = {
     FV: '1.0',
index 692e2f9a201e1bbb7cfbbfc1001dd7446236347d..9f78ed4ec25ee0264010ae7e6b7e8cd5c8570211 100644 (file)
@@ -1,4 +1,4 @@
-import { secondsToMilliseconds } from 'date-fns'
+import { millisecondsToSeconds, secondsToMilliseconds } from 'date-fns'
 
 import type { AuthCache, CacheStats } from '../interfaces/OCPPAuthService.js'
 import type { AuthorizationResult } from '../types/AuthTypes.js'
@@ -65,9 +65,6 @@ interface RateLimitStats {
  * - Bounded rate limits map prevents unbounded memory growth
  */
 export class InMemoryAuthCache implements AuthCache {
-  // Implementation-specific safety limit (not mandated by OCPP spec)
-  private static readonly DEFAULT_MAX_ABSOLUTE_LIFETIME_MS = 86_400_000 // 24 hours
-
   /** Cache storage: identifier -> entry */
   private readonly cache = new Map<string, CacheEntry>()
 
@@ -108,14 +105,14 @@ export class InMemoryAuthCache implements AuthCache {
   /**
    * Create an in-memory auth cache
    * @param options - Cache configuration options
-   * @param options.cleanupIntervalSeconds - Periodic cleanup interval in seconds (default: 300, 0 to disable)
-   * @param options.defaultTtl - Default TTL in seconds (default: 3600)
-   * @param options.maxAbsoluteLifetimeMs - Absolute lifetime cap in milliseconds (default: 86400000)
+   * @param options.cleanupIntervalSeconds - Periodic cleanup interval in seconds (default: Constants.DEFAULT_AUTH_CACHE_CLEANUP_INTERVAL_SECONDS, 0 to disable)
+   * @param options.defaultTtl - Default TTL in seconds (default: Constants.DEFAULT_AUTH_CACHE_TTL_SECONDS)
+   * @param options.maxAbsoluteLifetimeMs - Absolute lifetime cap in milliseconds (default: Constants.DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS)
    * @param options.maxEntries - Maximum number of cache entries (default: Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES)
    * @param options.rateLimit - Rate limiting configuration
    * @param options.rateLimit.enabled - Enable rate limiting (default: false)
-   * @param options.rateLimit.maxRequests - Max requests per window (default: 10)
-   * @param options.rateLimit.windowMs - Time window in milliseconds (default: 60000)
+   * @param options.rateLimit.maxRequests - Max requests per window (default: Constants.DEFAULT_AUTH_CACHE_RATE_LIMIT_MAX_REQUESTS)
+   * @param options.rateLimit.windowMs - Time window in milliseconds (default: Constants.DEFAULT_AUTH_CACHE_RATE_LIMIT_WINDOW_MS)
    */
   constructor (options?: {
     cleanupIntervalSeconds?: number
@@ -126,7 +123,7 @@ export class InMemoryAuthCache implements AuthCache {
   }) {
     this.defaultTtl = options?.defaultTtl ?? Constants.DEFAULT_AUTH_CACHE_TTL_SECONDS
     this.maxAbsoluteLifetimeMs =
-      options?.maxAbsoluteLifetimeMs ?? InMemoryAuthCache.DEFAULT_MAX_ABSOLUTE_LIFETIME_MS
+      options?.maxAbsoluteLifetimeMs ?? Constants.DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS
     this.maxEntries = Math.max(1, options?.maxEntries ?? Constants.DEFAULT_AUTH_CACHE_MAX_ENTRIES)
     this.rateLimit = {
       enabled: options?.rateLimit?.enabled ?? false,
@@ -341,7 +338,7 @@ export class InMemoryAuthCache implements AuthCache {
     }
 
     const ttlSeconds = ttl ?? this.defaultTtl
-    const maxTtlSeconds = this.maxAbsoluteLifetimeMs / 1000
+    const maxTtlSeconds = millisecondsToSeconds(this.maxAbsoluteLifetimeMs)
     const clampedTtl = Math.min(Math.max(0, ttlSeconds), maxTtlSeconds)
     const now = Date.now()
     const expiresAt = now + secondsToMilliseconds(clampedTtl)
index a7c71129b754600c432232500590b64ada73a4b9..b844de1a0838319b68611bb55ed0459b94b930be 100644 (file)
@@ -12,11 +12,7 @@ export { InMemoryLocalAuthListManager } from './cache/InMemoryLocalAuthListManag
 export type {
   AuthCache,
   AuthComponentFactory,
-  AuthStats,
   AuthStrategy,
-  CacheStats,
-  CertificateAuthProvider,
-  CertificateInfo,
   DifferentialAuthEntry,
   LocalAuthEntry,
   LocalAuthListManager,
index 8252dc846a1997166832f5f59dad553f35428881..d8bf79d1418fb2749e9baceecb58c3e7f5bf250a 100644 (file)
@@ -10,6 +10,7 @@ import type {
 import type { AuthConfiguration, AuthorizationResult, AuthRequest } from '../types/AuthTypes.js'
 
 import {
+  Constants,
   ensureError,
   getErrorMessage,
   logger,
@@ -382,8 +383,7 @@ export class RemoteAuthStrategy implements AuthStrategy {
     }
 
     try {
-      // Use provided TTL or default cache lifetime
-      const cacheTtl = ttl ?? result.cacheTtl ?? 300 // Default 5 minutes
+      const cacheTtl = ttl ?? result.cacheTtl ?? Constants.DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS
       this.authCache.set(identifier, result, cacheTtl)
       logger.debug(
         `${moduleName}: Cached result for '${truncateId(identifier)}' (TTL: ${String(cacheTtl)}s)`
index 8370e4e77305bf4613ef517977d5598ad42d7a93..70d2972e047f474268c118819c8881d361293079 100644 (file)
@@ -1,8 +1,14 @@
-import { isNotEmptyArray, logger } from '../../../../utils/index.js'
+import { Constants, isNotEmptyArray, logger } from '../../../../utils/index.js'
 import { type AuthConfiguration, AuthenticationError, AuthErrorCode } from '../types/AuthTypes.js'
 
 const moduleName = 'AuthConfigValidator'
 
+const AUTH_CACHE_TTL_MIN_SECONDS = 60
+const AUTH_CACHE_TTL_MAX_SECONDS = Constants.SECONDS_PER_DAY
+const AUTH_CACHE_MIN_ENTRIES = 10
+const AUTH_TIMEOUT_MIN_SECONDS = 5
+const AUTH_TIMEOUT_MAX_SECONDS = 60
+
 /**
  * Warn if no authentication method is enabled in the configuration.
  * @param config - Authentication configuration to check
@@ -73,13 +79,13 @@ function validateCacheConfig (config: AuthConfiguration): void {
       )
     }
 
-    if (config.authorizationCacheLifetime < 60) {
+    if (config.authorizationCacheLifetime < AUTH_CACHE_TTL_MIN_SECONDS) {
       logger.warn(
-        `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least 60s for efficiency.`
+        `${moduleName}: authorizationCacheLifetime is very short (${String(config.authorizationCacheLifetime)}s). Consider using at least ${String(AUTH_CACHE_TTL_MIN_SECONDS)}s for efficiency.`
       )
     }
 
-    if (config.authorizationCacheLifetime > 86400) {
+    if (config.authorizationCacheLifetime > AUTH_CACHE_TTL_MAX_SECONDS) {
       logger.warn(
         `${moduleName}: authorizationCacheLifetime is very long (${String(config.authorizationCacheLifetime)}s). This may lead to stale authorizations.`
       )
@@ -101,7 +107,7 @@ function validateCacheConfig (config: AuthConfiguration): void {
       )
     }
 
-    if (config.maxCacheEntries < 10) {
+    if (config.maxCacheEntries < AUTH_CACHE_MIN_ENTRIES) {
       logger.warn(
         `${moduleName}: maxCacheEntries is very small (${String(config.maxCacheEntries)}). Cache may be ineffective with frequent evictions.`
       )
@@ -172,13 +178,13 @@ function validateTimeout (config: AuthConfiguration): void {
     )
   }
 
-  if (config.authorizationTimeout < 5) {
+  if (config.authorizationTimeout < AUTH_TIMEOUT_MIN_SECONDS) {
     logger.warn(
       `${moduleName}: authorizationTimeout is very short (${String(config.authorizationTimeout)}s). This may cause premature timeouts.`
     )
   }
 
-  if (config.authorizationTimeout > 60) {
+  if (config.authorizationTimeout > AUTH_TIMEOUT_MAX_SECONDS) {
     logger.warn(
       `${moduleName}: authorizationTimeout is very long (${String(config.authorizationTimeout)}s). Users may experience long waits.`
     )
index 76f2563928bb6a5ce6d12d28375ad57d51d2d2de..97ff29f092bdfb3fae60e06ad73b6168d93f1ccd 100644 (file)
@@ -1,5 +1,6 @@
 import type { WebSocket } from 'ws'
 
+import { millisecondsToSeconds } from 'date-fns'
 import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { type IncomingMessage, Server, type ServerResponse } from 'node:http'
 import { createServer, type Http2Server } from 'node:http2'
@@ -970,7 +971,7 @@ export abstract class AbstractUIServer {
       provider,
       'simulator_station_data_timestamp_seconds',
       'Unix epoch (seconds) at which the charging station snapshot was emitted.',
-      data => Math.floor(data.timestamp / 1000)
+      data => Math.floor(millisecondsToSeconds(data.timestamp))
     )
 
     addPerStationStatusInfo(
@@ -1147,7 +1148,9 @@ export abstract class AbstractUIServer {
       'simulator_connector_transaction_start_seconds',
       'Unix epoch (seconds) at which the active transaction started on the connector.',
       cs =>
-        cs.transactionStart != null ? Math.floor(cs.transactionStart.getTime() / 1000) : undefined
+        cs.transactionStart != null
+          ? Math.floor(millisecondsToSeconds(cs.transactionStart.getTime()))
+          : undefined
     )
     addConnectorNumeric(
       registry,
index c504af8178f146124b6e3adbb07ef0dab63f9bfd..dc693e78a159c64daa99f593ffbb16afb816c3e6 100644 (file)
@@ -2,7 +2,6 @@ import type { IncomingMessage } from 'node:http'
 
 import type { UIServerConfiguration } from '../../types/index.js'
 
-import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js'
 import {
   has,
   isEmpty,
@@ -10,6 +9,7 @@ import {
   isNotEmptyArray,
   normalizeHost,
   normalizeIPAddress,
+  UI_SERVER_ACCESS_POLICY_DEFAULTS,
 } from '../../utils/index.js'
 import { splitHeaderList, splitQuoted } from './UIServerNet.js'
 
index 015a1887a153c323d0d47bebaa573200b5804960..afcc1231da7162c7b7ff4f551188e3b4ccd3840a 100644 (file)
@@ -82,7 +82,7 @@ export class UIServerFactory {
         logger.warn(
           `${UIServerFactory.logPrefix(moduleName, 'getUIServerImplementation')} ${logMsg}`
         )
-        // eslint-disable-next-line @typescript-eslint/no-deprecated
+        // eslint-disable-next-line @typescript-eslint/no-deprecated -- UIHttpServer is @deprecated but remains selectable via configuration until removal
         return new UIHttpServer(uiServerConfiguration, bootstrap)
       }
       case ApplicationProtocol.MCP:
index be82f942522380e389804b792cf68d25f4a364d0..7954c68d747b8afdbc363568beda5fc969517370 100644 (file)
@@ -34,6 +34,12 @@ import {
 
 const moduleName = 'UIWebSocketServer'
 
+const WS_DEFLATE_CONCURRENCY_LIMIT = 10
+const WS_DEFLATE_SERVER_MAX_WINDOW_BITS = 12
+const WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES = 16 * 1024
+const WS_DEFLATE_ZLIB_COMPRESSION_LEVEL = 6
+const WS_DEFLATE_ZLIB_MEM_LEVEL = 7
+
 // Pre-handshake WS rejections write raw HTTP/1.1 to the Duplex socket;
 // AbstractUIServer.renderDenial targets ServerResponse and is not applicable.
 const buildUpgradeRejectionResponse = (
@@ -68,17 +74,17 @@ export class UIWebSocketServer extends AbstractUIServer {
       noServer: true,
       perMessageDeflate: {
         clientNoContextTakeover: true,
-        concurrencyLimit: 10,
-        serverMaxWindowBits: 12,
+        concurrencyLimit: WS_DEFLATE_CONCURRENCY_LIMIT,
+        serverMaxWindowBits: WS_DEFLATE_SERVER_MAX_WINDOW_BITS,
         serverNoContextTakeover: true,
         threshold: DEFAULT_COMPRESSION_THRESHOLD_BYTES,
         zlibDeflateOptions: {
-          chunkSize: 16 * 1024,
-          level: 6,
-          memLevel: 7,
+          chunkSize: WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES,
+          level: WS_DEFLATE_ZLIB_COMPRESSION_LEVEL,
+          memLevel: WS_DEFLATE_ZLIB_MEM_LEVEL,
         },
         zlibInflateOptions: {
-          chunkSize: 16 * 1024,
+          chunkSize: WS_DEFLATE_ZLIB_CHUNK_SIZE_BYTES,
         },
       },
     })
index 434a14de025f00da08de4cfb652ea2e43451c846..5e269d1a3dbe9a5a5ce479c2281c5f488802c193 100644 (file)
@@ -1,4 +1,3 @@
 export { registerMCPResources, registerMCPSchemaResources } from './MCPResources.js'
 export { registerMCPLogTools } from './MCPTools.js'
 export { mcpToolSchemas, ocppSchemaMapping } from './MCPToolSchemas.js'
-export type { MCPToolSchema } from './MCPToolSchemas.js'
index 79a94450448b17709fb575c28bbd600317528ce1..d19aebb3c9222dbfd3e04d9634bd11ca811cee3e 100644 (file)
@@ -41,6 +41,8 @@ import {
 
 const moduleName = 'PerformanceStatistics'
 
+const STATISTICS_PERCENTILE = 95
+
 export class PerformanceStatistics {
   private static readonly instances: Map<string, PerformanceStatistics> = new Map<
     string,
@@ -241,7 +243,7 @@ export class PerformanceStatistics {
       entryStatisticsData.medTimeMeasurement = median(timeMeasurementValues)
       entryStatisticsData.ninetyFiveThPercentileTimeMeasurement = percentile(
         timeMeasurementValues,
-        95
+        STATISTICS_PERCENTILE
       )
       entryStatisticsData.stdTimeMeasurement = std(
         timeMeasurementValues,
index 5cd0489935ee93d54de4bf06719fbafac67fdd15..9fd769e3060665a8e04be48d0a948238c8534c8b 100644 (file)
@@ -6,6 +6,11 @@ import {
   VendorParametersKey,
 } from '../types/index.js'
 
+// Shared literals for class-static members below (TS2729 forbids static
+// forward-reference).
+const DAY_IN_MS = 86_400_000
+const DAY_IN_SECONDS = 86_400
+
 // eslint-disable-next-line @typescript-eslint/no-extraneous-class
 export class Constants {
   static readonly DEFAULT_ATG_CONFIGURATION: Readonly<AutomaticTransactionGeneratorConfiguration> =
@@ -24,6 +29,9 @@ export class Constants {
 
   static readonly DEFAULT_AUTH_CACHE_CLEANUP_INTERVAL_SECONDS = 300
 
+  /** Implementation-specific upper bound for auth cache entry absolute lifetime (24 hours). Not mandated by OCPP spec. */
+  static readonly DEFAULT_AUTH_CACHE_MAX_ABSOLUTE_LIFETIME_MS = DAY_IN_MS
+
   static readonly DEFAULT_AUTH_CACHE_MAX_ENTRIES = 1000
 
   static readonly DEFAULT_AUTH_CACHE_RATE_LIMIT_MAX_REQUESTS = 10
@@ -64,6 +72,17 @@ export class Constants {
 
   static readonly DEFAULT_PERFORMANCE_RECORDS_DB_NAME = 'e-mobility-charging-stations-simulator'
   static readonly DEFAULT_PERFORMANCE_RECORDS_FILENAME = 'performanceRecords.json'
+
+  /**
+   * Peak jitter fraction: consumers scale the base delay by a uniform draw in
+   * `[0, jitterPercent)`. See `computeExponentialBackOffDelay` (uni-directional
+   * positive) and `randomizeDelay` (asymmetric with a probability-zero gap).
+   */
+  static readonly DEFAULT_RECONNECT_JITTER_PERCENT = 0.2
+
+  /** Default cache TTL for remote authorization results, distinct from `DEFAULT_AUTH_CACHE_TTL_SECONDS` (3600, local cache default). */
+  static readonly DEFAULT_REMOTE_AUTH_CACHE_TTL_SECONDS = 300
+
   static readonly DEFAULT_STATION_INFO: Readonly<Partial<ChargingStationInfo>> = Object.freeze({
     automaticTransactionGeneratorPersistentConfiguration: true,
     autoReconnectMaxRetries: -1,
@@ -126,15 +145,30 @@ export class Constants {
   // Values exceeding this limit cause Node.js to reset the delay to 1ms
   static readonly MAX_SETINTERVAL_DELAY_MS = 2147483647
 
+  /** Milliseconds per day; equal to `24 * MS_PER_HOUR`. */
+  static readonly MS_PER_DAY = DAY_IN_MS
+
   /** Milliseconds per hour; conversion factor for `Wh` accrual from `W·ms`. */
   static readonly MS_PER_HOUR = 3_600_000
 
   static readonly PERFORMANCE_RECORDS_TABLE = 'performance_records'
 
+  /** Seconds per day; used for day-normalized time arithmetic. */
+  static readonly SECONDS_PER_DAY = DAY_IN_SECONDS
+
+  /** State of Charge maximum percentage (upper bound for SoC measurand emission). */
+  static readonly SOC_MAXIMUM_PERCENT = 100
+
   static readonly STOP_CHARGING_STATIONS_TIMEOUT_MS = 60000
 
   static readonly STOP_MESSAGE_SEQUENCE_TIMEOUT_MS = 30000
 
-  /** Divider between base units (W, Wh) and kilo units (kW, kWh). */
+  /** Divider between base units (A) and centi units (cA). */
+  static readonly UNIT_DIVIDER_CENTI = 100
+
+  /** Divider between base units (A) and deci units (dA). */
+  static readonly UNIT_DIVIDER_DECI = 10
+
+  /** Divider between base units (W, Wh, A) and kilo/milli units (kW, kWh, mA). */
   static readonly UNIT_DIVIDER_KILO = 1000
 }
index 7e99cedb0104b359ea791778a9b8e07536c3cd54..8155bc4ea2224bd723a095620ca7ab77a3778b5b 100644 (file)
@@ -49,7 +49,7 @@ export const percentile = (dataSet: number[], percentile: number): number => {
   }
   const base = (percentile / 100) * (sortedDataSet.length - 1)
   const baseIndex = Math.floor(base)
-  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime out-of-bounds guard: baseIndex+1 can equal sortedDataSet.length; index access is typed as `number` without `noUncheckedIndexedAccess`
   if (sortedDataSet[baseIndex + 1] != null) {
     return (
       sortedDataSet[baseIndex] +
index 1f94218ca75548b5c22ccd5e69503fdfab419888..cd1361f61da6a6e8c3dcb6232c060a8599252bdc 100644 (file)
@@ -195,14 +195,14 @@ export const formatDurationMilliSeconds = (duration: number): string => {
   if (duration < 0) {
     throw new RangeError('Duration cannot be negative')
   }
-  const days = Math.floor(duration / (24 * 3600 * 1000))
+  const days = Math.floor(duration / Constants.MS_PER_DAY)
   const hours = Math.floor(millisecondsToHours(duration) - days * 24)
   const minutes = Math.floor(
     millisecondsToMinutes(duration) - days * 24 * 60 - hoursToMinutes(hours)
   )
   const seconds = Math.floor(
     millisecondsToSeconds(duration) -
-      days * 24 * 3600 -
+      days * Constants.SECONDS_PER_DAY -
       hoursToSeconds(hours) -
       minutesToSeconds(minutes)
   )
@@ -385,7 +385,7 @@ export const clone = <T>(object: T): T => {
 
 type AsyncFunctionType<A extends unknown[], R> = (...args: A) => PromiseLike<R>
 
-// eslint-disable-next-line @typescript-eslint/no-empty-function
+// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op async lambda used only to capture its constructor for isAsyncFunction
 const AsyncFunctionConstructor = (async () => {}).constructor
 
 /**
index 435a341f876135342c4788e4775c821a8a45045d..1862006396a9b8d0c6c98a0934f0e46d35f72d96 100644 (file)
@@ -9,11 +9,8 @@ export {
 } from './ChargingStationConfigurationUtils.js'
 export { Configuration, DEFAULT_PERSIST_STATE } from './Configuration.js'
 export {
-  applyConfigurationMigration,
-  coerceConfigurationVersion,
   CURRENT_CONFIGURATION_SCHEMA_VERSION,
   DEPRECATED_KEY_REMAPPINGS,
-  type FieldError,
   remapDeprecatedKeys,
 } from './ConfigurationMigrations.js'
 export {
@@ -103,7 +100,6 @@ export {
   mergeDeepRight,
   once,
   promiseWithTimeout,
-  queueMicrotaskErrorThrowing,
   roundTo,
   secureRandom,
   sleep,
index 0ff59557754e3ff5aadd495edb55d64046351752..bd521f98d7c31ca3d52a9c16b95395fd64abe903 100644 (file)
@@ -1,6 +1,14 @@
 import chalk from 'chalk'
 import { getRandomValues } from 'node:crypto'
 
+/**
+ * Peak jitter fraction for randomized worker delays. Mirrors
+ * `Constants.DEFAULT_RECONNECT_JITTER_PERCENT`; `src/worker/` has no
+ * cross-module value imports. See `randomizeDelay` for the asymmetric
+ * distribution `(−10 %, 0] ∪ [+10 %, +20 %)`.
+ */
+const JITTER_PERCENT = 0.2
+
 const isPlainObject = (value: unknown): value is Record<string, unknown> => {
   if (typeof value !== 'object' || value === null) return false
   return Object.prototype.toString.call(value).slice(8, -1) === 'Object'
@@ -49,7 +57,7 @@ export const defaultErrorHandler = (error: Error): void => {
 export const randomizeDelay = (delay: number): number => {
   const random = secureRandom()
   const sign = random < 0.5 ? -1 : 1
-  const randomSum = delay * 0.2 * random // 0-20% of the delay
+  const randomSum = delay * JITTER_PERCENT * random
   return delay + sign * randomSum
 }
 
index ad7b0c2e1fece92b80eded1d7943a36e49c82d1f..a3c1b24d3d220178fa7c24295ca23254e8e917b3 100644 (file)
@@ -1,3 +1,4 @@
+import { secondsToMilliseconds } from 'date-fns'
 /**
  * @file Tests for ChargingStation Configuration Management
  * @description Unit tests for boot notification, config persistence, and WebSocket handling
@@ -8,6 +9,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 import type { ChargingStation } from '../../src/charging-station/index.js'
 
 import { AvailabilityType, RegistrationStatusEnumType } from '../../src/types/index.js'
+import { Constants } from '../../src/utils/index.js'
 import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js'
 import { TEST_HEARTBEAT_INTERVAL_MS, TEST_ONE_HOUR_MS } from './ChargingStationTestConstants.js'
 import { cleanupChargingStation, createMockChargingStation } from './helpers/StationHelpers.js'
@@ -76,7 +78,7 @@ await describe('ChargingStation Configuration Management', async () => {
       station = result.station
 
       // Assert - Heartbeat interval should match response interval
-      assert.strictEqual(station.getHeartbeatInterval(), customInterval * 1000)
+      assert.strictEqual(station.getHeartbeatInterval(), secondsToMilliseconds(customInterval))
       assert.strictEqual(station.inPendingState(), true)
     })
 
@@ -215,7 +217,10 @@ await describe('ChargingStation Configuration Management', async () => {
       // Assert - Both should have their respective intervals
       assert.strictEqual(pendingStation.station.inPendingState(), true)
       assert.strictEqual(rejectedStation.station.inRejectedState(), true)
-      assert.strictEqual(pendingStation.station.getHeartbeatInterval(), 60000)
+      assert.strictEqual(
+        pendingStation.station.getHeartbeatInterval(),
+        Constants.DEFAULT_HEARTBEAT_INTERVAL_MS
+      )
       assert.strictEqual(rejectedStation.station.getHeartbeatInterval(), TEST_ONE_HOUR_MS)
 
       // Cleanup
@@ -265,16 +270,16 @@ await describe('ChargingStation Configuration Management', async () => {
       station = result.station
 
       // Act & Assert - should convert seconds to milliseconds
-      assert.strictEqual(station.getHeartbeatInterval(), 60000)
+      assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS)
     })
 
-    await it('should return default heartbeat interval when not explicitly configured', () => {
-      // Arrange - use default heartbeat interval (TEST_HEARTBEAT_INTERVAL_SECONDS = 60)
+    await it('should return default heartbeat interval when not set', () => {
+      // Arrange
       const result = createMockChargingStation()
       station = result.station
 
-      // Act & Assert - default 60s * 1000 = 60000ms
-      assert.strictEqual(station.getHeartbeatInterval(), 60000)
+      // Act & Assert - default 60s in ms
+      assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS)
     })
 
     await it('should return connection timeout in milliseconds', () => {
@@ -337,15 +342,15 @@ await describe('ChargingStation Configuration Management', async () => {
       const result = createMockChargingStation({ heartbeatInterval: 60 })
       station = result.station
       const initialInterval = station.getHeartbeatInterval()
-      assert.strictEqual(initialInterval, 60000)
+      assert.strictEqual(initialInterval, Constants.DEFAULT_HEARTBEAT_INTERVAL_MS)
 
       // Act - simulate configuration change by creating new station with different interval
       const result2 = createMockChargingStation({ heartbeatInterval: 120 })
       const station2 = result2.station
 
       // Assert - different configurations have different intervals
-      assert.strictEqual(station2.getHeartbeatInterval(), 120000)
-      assert.strictEqual(station.getHeartbeatInterval(), 60000) // Original unchanged
+      assert.strictEqual(station2.getHeartbeatInterval(), secondsToMilliseconds(120))
+      assert.strictEqual(station.getHeartbeatInterval(), Constants.DEFAULT_HEARTBEAT_INTERVAL_MS) // Original unchanged
 
       // Cleanup second station
       cleanupChargingStation(station2)
index 22beb41c0382e2a62624f47456a01e7be3128a8f..b6975cddd6e3ebf43c952536f8ffe3abc4866d36 100644 (file)
@@ -5,6 +5,7 @@
  * buildSignedOCPP16SampledValue, and periodic meter values.
  */
 
+import { minutesToMilliseconds } from 'date-fns'
 import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
@@ -456,7 +457,7 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => {
 
         // Act
         OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
 
         // Assert
         assert.ok(capturedMeterValue != null)
@@ -481,7 +482,7 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => {
 
         // Act
         OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
 
         // Assert
         assert.ok(capturedMeterValue != null)
index 6861983b8feaeaf542b64a84bace56c227d9ef13..8f382eccea1c31d0888bf509cdfcf368791fccc3 100644 (file)
@@ -3,6 +3,7 @@
  * @description Verifies certificate signing retry lifecycle per OCPP 2.0.1 A02.FR.17-19
  */
 
+import { minutesToMilliseconds } from 'date-fns'
 import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
@@ -112,7 +113,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
 
         // Act
         manager.startRetryTimer()
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
 
         // Assert
@@ -132,7 +133,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
         )
 
         manager.startRetryTimer()
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
 
         assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
@@ -163,7 +164,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
 
         // Act
         manager.cancelRetryTimer()
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
 
         // Assert
@@ -193,7 +194,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
         await flushMicrotasks()
 
         // Assert — no further retry despite Accepted response
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
         assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
       })
@@ -257,7 +258,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
         assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
 
         // Assert — no third retry, max reached
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
         assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
       })
@@ -304,7 +305,7 @@ await describe('OCPP20CertSigningRetryManager', async () => {
 
         // Assert
         assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
-        t.mock.timers.tick(60000)
+        t.mock.timers.tick(minutesToMilliseconds(1))
         await flushMicrotasks()
         assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
       })
index aaa28c7d380983a2f67ad98bca72f8af8dada152..9ed84f0e4197a5cf275c2572f7d61f6436aada82 100644 (file)
@@ -5,6 +5,7 @@
 
 import type { IncomingMessage } from 'node:http'
 
+import { minutesToMilliseconds } from 'date-fns'
 import assert from 'node:assert/strict'
 import { Readable } from 'node:stream'
 import { afterEach, describe, it } from 'node:test'
@@ -113,7 +114,7 @@ await describe('UIServerSecurity', async () => {
     })
 
     await it('should reject new IPs when at max tracked capacity', () => {
-      limiter = createRateLimiter(10, 60000, 3)
+      limiter = createRateLimiter(10, minutesToMilliseconds(1), 3)
 
       assert.strictEqual(limiter('192.168.1.1'), true)
       assert.strictEqual(limiter('192.168.1.2'), true)
@@ -122,7 +123,7 @@ await describe('UIServerSecurity', async () => {
     })
 
     await it('should allow existing IPs when at max capacity', () => {
-      limiter = createRateLimiter(10, 60000, 2)
+      limiter = createRateLimiter(10, minutesToMilliseconds(1), 2)
 
       assert.strictEqual(limiter('192.168.1.1'), true)
       assert.strictEqual(limiter('192.168.1.2'), true)
index ae7b63c3d4bd1d89e7d976a34385b21c72e9eaea..276f0db701743dc0f35323ea5b489972844fd1b6 100644 (file)
@@ -1,5 +1,7 @@
 import type { Statistics } from '../../../src/types/index.js'
 
+import { TEST_SUPERVISION_URL } from '../../utils/TestNetworkConstants.js'
+
 /**
  * @param id - Performance record identifier
  * @param name - Charging station name
@@ -26,6 +28,6 @@ export function buildTestStatistics (id: string, name?: string): Statistics {
     id,
     name: name ?? `cs-${id}`,
     statisticsData: statsData,
-    uri: 'ws://localhost:8080',
+    uri: TEST_SUPERVISION_URL,
   }
 }
index 63d0db752bc059380ca74af9cd2632e27185734e..ccb77145156bb3cbdda63d7d96e3bbaa1ff75a1c 100644 (file)
@@ -14,12 +14,13 @@ import {
   CURRENT_CONFIGURATION_SCHEMA_VERSION,
   DEPRECATED_KEY_REMAPPINGS,
   remapDeprecatedKeys,
-} from '../../src/utils/index.js'
+} from '../../src/utils/ConfigurationMigrations.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import {
   buildLegacyConfiguration,
   buildV0WithDeprecatedKeyCollision,
 } from './helpers/ConfigurationFixtures.js'
+import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js'
 
 await describe('ConfigurationMigrations', async () => {
   afterEach(() => {
@@ -127,7 +128,7 @@ await describe('ConfigurationMigrations', async () => {
         logEnabled: true,
         logFile: '/logs/combined.log',
         stationTemplateURLs: [{ file: 'a.json', numberOfStations: 1 }],
-        supervisionURLs: 'ws://localhost:8080',
+        supervisionURLs: TEST_SUPERVISION_URL,
         workerProcess: 'workerSet',
       })
 
@@ -138,7 +139,7 @@ await describe('ConfigurationMigrations', async () => {
       assert.strictEqual((result.log as Record<string, unknown>).enabled, true)
       assert.strictEqual((result.log as Record<string, unknown>).file, '/logs/combined.log')
       assert.strictEqual((result.worker as Record<string, unknown>).processType, 'workerSet')
-      assert.strictEqual(result.supervisionUrls, 'ws://localhost:8080')
+      assert.strictEqual(result.supervisionUrls, TEST_SUPERVISION_URL)
       assert.ok(Array.isArray(result.stationTemplateUrls))
       assert.strictEqual(result.logEnabled, undefined)
       assert.strictEqual(result.logFile, undefined)
index ae35f7bb2104c2c16ea82daca878cfb2f7ee85f9..da5d86cdacd5928af66213f840da57944e4ffe0d 100644 (file)
@@ -24,6 +24,7 @@ import {
   createMockChargingStation,
 } from '../charging-station/helpers/StationHelpers.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { TEST_SUPERVISION_URL } from './TestNetworkConstants.js'
 
 await describe('MessageChannelUtils', async () => {
   let station: ChargingStation
@@ -39,7 +40,7 @@ await describe('MessageChannelUtils', async () => {
     // Add wsConnectionUrl getter needed by MessageChannelUtils builders
     Object.defineProperty(station, 'wsConnectionUrl', {
       configurable: true,
-      get: () => new URL('ws://localhost:8080/CS-TEST-00001'),
+      get: () => new URL(`${TEST_SUPERVISION_URL}/CS-TEST-00001`),
     })
   })
 
@@ -55,7 +56,7 @@ await describe('MessageChannelUtils', async () => {
     assert.notStrictEqual(message.data, undefined)
     assert.strictEqual(message.data.started, true)
     assert.strictEqual(message.data.stationInfo.chargingStationId, 'CS-TEST-00001')
-    assert.strictEqual(message.data.supervisionUrl, 'ws://localhost:8080/CS-TEST-00001')
+    assert.strictEqual(message.data.supervisionUrl, `${TEST_SUPERVISION_URL}/CS-TEST-00001`)
     assert.strictEqual(typeof message.data.timestamp, 'number')
   })
 
@@ -79,7 +80,7 @@ await describe('MessageChannelUtils', async () => {
 
     assert.strictEqual(message.event, ChargingStationWorkerMessageEvents.stopped)
     assert.notStrictEqual(message.data, undefined)
-    assert.strictEqual(message.data.supervisionUrl, 'ws://localhost:8080/CS-TEST-00001')
+    assert.strictEqual(message.data.supervisionUrl, `${TEST_SUPERVISION_URL}/CS-TEST-00001`)
   })
 
   await it('should build updated message with correct event', () => {
@@ -122,7 +123,7 @@ await describe('MessageChannelUtils', async () => {
           },
         ],
       ]),
-      uri: 'ws://localhost:8080',
+      uri: TEST_SUPERVISION_URL,
     }
 
     const message = buildPerformanceStatisticsMessage(statistics)
@@ -154,7 +155,7 @@ await describe('MessageChannelUtils', async () => {
           },
         ],
       ]),
-      uri: 'ws://localhost:8080',
+      uri: TEST_SUPERVISION_URL,
     }
 
     const message = buildPerformanceStatisticsMessage(statistics)
@@ -172,13 +173,13 @@ await describe('MessageChannelUtils', async () => {
       name: 'station-name',
       statisticsData: new Map(),
       updatedAt,
-      uri: 'ws://localhost:8080',
+      uri: TEST_SUPERVISION_URL,
     }
 
     const message = buildPerformanceStatisticsMessage(statistics)
 
     assert.strictEqual(message.data.createdAt, createdAt)
     assert.strictEqual(message.data.updatedAt, updatedAt)
-    assert.strictEqual(message.data.uri, 'ws://localhost:8080')
+    assert.strictEqual(message.data.uri, TEST_SUPERVISION_URL)
   })
 })
diff --git a/tests/utils/TestNetworkConstants.ts b/tests/utils/TestNetworkConstants.ts
new file mode 100644 (file)
index 0000000..b41b5cc
--- /dev/null
@@ -0,0 +1,10 @@
+/**
+ * @file Shared network-related test constants derived from production defaults.
+ * @description Single source of truth for the `ws://host:port` test URL,
+ *   built from `Constants.DEFAULT_UI_SERVER_HOST` /
+ *   `Constants.DEFAULT_UI_SERVER_PORT` so a change to the production defaults
+ *   propagates automatically to the tests.
+ */
+import { Constants } from '../../src/utils/index.js'
+
+export const TEST_SUPERVISION_URL = `ws://${Constants.DEFAULT_UI_SERVER_HOST}:${Constants.DEFAULT_UI_SERVER_PORT.toString()}`
index fae297f4484df3d9e105d26d3c294305d0f8adb2..86a1b1d909cffeb773db550069ab2ab65ae00927 100644 (file)
@@ -48,7 +48,6 @@ import {
   mergeDeepRight,
   once,
   promiseWithTimeout,
-  queueMicrotaskErrorThrowing,
   roundTo,
   secureRandom,
   sleep,
@@ -56,6 +55,7 @@ import {
   validateIdentifierString,
   validateUUID,
 } from '../../src/utils/index.js'
+import { queueMicrotaskErrorThrowing } from '../../src/utils/Utils.js'
 import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js'
 
 await describe('Utils', async () => {
index f85b6f241bcdc09e3b2d597ad2a758cfb3ac4c52..a8db2e00f3afa9a39f80753034b0a4a00ec6d768 100644 (file)
@@ -2,7 +2,8 @@
  * @file Shared configuration fixtures for schema/migration/validation tests.
  */
 
-import { CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../../src/utils/index.js'
+import { Constants, CURRENT_CONFIGURATION_SCHEMA_VERSION } from '../../../src/utils/index.js'
+import { TEST_SUPERVISION_URL } from '../TestNetworkConstants.js'
 
 /**
  * Build a minimal valid configuration at the current schema version.
@@ -60,10 +61,10 @@ export const buildFullConfiguration = (): Record<string, unknown> => ({
     { file: 'full.station-template.json', numberOfStations: 2, provisionedNumberOfStations: 4 },
   ],
   supervisionUrlDistribution: 'charging-station-affinity',
-  supervisionUrls: ['ws://localhost:8080/ocpp'],
+  supervisionUrls: [`${TEST_SUPERVISION_URL}/ocpp`],
   uiServer: {
     enabled: false,
-    options: { host: 'localhost', port: 8080 },
+    options: { host: Constants.DEFAULT_UI_SERVER_HOST, port: Constants.DEFAULT_UI_SERVER_PORT },
     type: 'ws',
     version: '1.1',
   },
index 65667e63deef43f7e13a54248b57fce7bc9b8e60..7d5c2347c1267723db6513b6ee66c1c088fe07fd 100644 (file)
@@ -16,6 +16,11 @@ import type { Formatter } from '../output/formatter.js'
 
 import { createWsAdapter } from './ws-adapter.js'
 
+// POSIX convention: signal-triggered exits use 128 + signal number
+// (see ui/cli/README.md "Exit Codes" table for the canonical mapping)
+const SIGINT_EXIT_CODE = 130
+const SIGTERM_EXIT_CODE = 143
+
 const wsFactory: WebSocketFactory = (url, protocols) =>
   createWsAdapter(new WsWebSocket(url, protocols))
 
@@ -109,9 +114,9 @@ export const registerSignalHandlers = (): void => {
   }
 
   process.on('SIGINT', () => {
-    cleanup(130)
+    cleanup(SIGINT_EXIT_CODE)
   })
   process.on('SIGTERM', () => {
-    cleanup(143)
+    cleanup(SIGTERM_EXIT_CODE)
   })
 }
index b7bcf229557c8b59a6c23191ea70971879124618..2bb828434e78fc8c05394a334dc5c4f79beab2d1 100644 (file)
@@ -1,6 +1,12 @@
 import chalk from 'chalk'
 import Table from 'cli-table3'
-import { type ConnectorEntry, type EvseEntry } from 'ui-common'
+import { millisecondsToSeconds } from 'date-fns'
+import {
+  type ConnectorEntry,
+  type EvseEntry,
+  OCPP16ChargePointStatus,
+  WebSocketReadyState,
+} from 'ui-common'
 
 const NO_BORDER = {
   bottom: '',
@@ -20,6 +26,10 @@ const NO_BORDER = {
   'top-right': '',
 }
 
+export const EMPTY_VALUE_PLACEHOLDER = '–'
+const TEMPLATE_NAME_SUFFIX = '.station-template'
+const TRUNCATED_HASH_ID_LENGTH = 12
+
 // cspell:ignore borderless
 export const borderlessTable = (head: string[], colWidths?: number[]): Table.Table =>
   new Table({
@@ -29,7 +39,10 @@ export const borderlessTable = (head: string[], colWidths?: number[]): Table.Tab
     ...(colWidths != null && { colWidths }),
   })
 
-export const truncateId = (id: string, len = 12): string =>
+export const stripTemplateSuffix = (name: string): string =>
+  name.endsWith(TEMPLATE_NAME_SUFFIX) ? name.slice(0, -TEMPLATE_NAME_SUFFIX.length) : name
+
+export const truncateId = (id: string, len = TRUNCATED_HASH_ID_LENGTH): string =>
   id.length > len ? `${id.slice(0, len)}…` : id
 
 export const statusIcon = (started: boolean | undefined): string =>
@@ -37,22 +50,22 @@ export const statusIcon = (started: boolean | undefined): string =>
 
 export const wsIcon = (wsState: number | undefined): string => {
   switch (wsState) {
-    case 0:
+    case WebSocketReadyState.CLOSED:
+    case WebSocketReadyState.CLOSING:
+      return chalk.red('✗')
+    case WebSocketReadyState.CONNECTING:
       return chalk.yellow('…')
-    case 1:
+    case WebSocketReadyState.OPEN:
       return chalk.green('✓')
-    case 2:
-    case 3:
-      return chalk.red('✗')
     default:
-      return chalk.dim('–')
+      return chalk.dim(EMPTY_VALUE_PLACEHOLDER)
   }
 }
 
 const STATUS_ABBREVIATIONS: Record<string, string> = {
-  Finishing: 'Fi',
-  SuspendedEV: 'SE',
-  SuspendedEVSE: 'SS',
+  [OCPP16ChargePointStatus.FINISHING]: 'Fi',
+  [OCPP16ChargePointStatus.SUSPENDED_EV]: 'SE',
+  [OCPP16ChargePointStatus.SUSPENDED_EVSE]: 'SS',
 }
 
 const statusLetter = (status: string | undefined): string => {
@@ -81,13 +94,13 @@ export const formatConnectors = (evses: EvseEntry[], connectors: ConnectorEntry[
     }
   }
 
-  return entries.length > 0 ? chalk.dim(entries.join(' ')) : chalk.dim('–')
+  return entries.length > 0 ? chalk.dim(entries.join(' ')) : chalk.dim(EMPTY_VALUE_PLACEHOLDER)
 }
 
 export const fuzzyTime = (ts: number | undefined): string => {
-  if (ts == null) return chalk.dim('–')
+  if (ts == null) return chalk.dim(EMPTY_VALUE_PLACEHOLDER)
   const diff = Math.max(0, Date.now() - ts)
-  const seconds = Math.floor(diff / 1000)
+  const seconds = Math.floor(millisecondsToSeconds(diff))
   if (seconds < 60) return chalk.dim('just now')
   const minutes = Math.floor(seconds / 60)
   if (minutes < 60) return chalk.dim(`${minutes.toString()}m ago`)
index 5755ebfe278187372f95718eeaaa50ce93da279d..a98f42b38bd96329cc1af2bd2a448731d6bf3234 100644 (file)
@@ -2,14 +2,17 @@ import type { ResponsePayload } from 'ui-common'
 
 import chalk from 'chalk'
 import process from 'node:process'
+import { WebSocketReadyState } from 'ui-common'
 
 import type { StationListPayload } from '../types.js'
 
 import {
   borderlessTable,
+  EMPTY_VALUE_PLACEHOLDER,
   formatConnectors,
   fuzzyTime,
   statusIcon,
+  stripTemplateSuffix,
   truncateId,
   wsIcon,
 } from './format.js'
@@ -83,7 +86,9 @@ const renderSimulatorState = (payload: SimulatorStatePayload): void => {
   process.stdout.write(`  Version    ${state.version}\n`)
   if (state.configuration?.worker != null) {
     const w = state.configuration.worker
-    process.stdout.write(`  Worker     ${w.processType ?? '–'} (${w.elementsPerWorker ?? '–'})\n`)
+    process.stdout.write(
+      `  Worker     ${w.processType ?? EMPTY_VALUE_PLACEHOLDER} (${w.elementsPerWorker ?? EMPTY_VALUE_PLACEHOLDER})\n`
+    )
   }
   if (
     state.configuration?.supervisionUrls != null &&
@@ -101,7 +106,7 @@ const renderSimulatorState = (payload: SimulatorStatePayload): void => {
     const table = borderlessTable(['Name', 'Added', 'Started', 'Provisioned', 'Configured'])
     for (const [name, s] of activeTemplates) {
       table.push([
-        name.replace('.station-template', ''),
+        stripTemplateSuffix(name),
         s.added > 0 ? chalk.green(s.added.toString()) : chalk.dim('0'),
         s.started > 0 ? chalk.green(s.started.toString()) : chalk.dim('0'),
         s.provisioned > 0 ? s.provisioned.toString() : chalk.dim('0'),
@@ -145,18 +150,18 @@ const renderStationList = (payload: StationListPayload): void => {
       statusIcon(cs.started),
       si.chargingStationId,
       chalk.dim(truncateId(si.hashId)),
-      reg ?? chalk.dim('–'),
+      reg ?? chalk.dim(EMPTY_VALUE_PLACEHOLDER),
       wsIcon(cs.wsState),
       formatConnectors(cs.evses ?? [], cs.connectors ?? []),
-      chalk.dim(si.ocppVersion ?? '–'),
-      chalk.dim(si.templateName.replace('.station-template', '')),
+      chalk.dim(si.ocppVersion ?? EMPTY_VALUE_PLACEHOLDER),
+      chalk.dim(stripTemplateSuffix(si.templateName)),
       fuzzyTime(cs.timestamp),
     ])
   }
   process.stdout.write(`${table.toString()}\n`)
 
   const started = stations.filter(s => s.started).length
-  const connected = stations.filter(s => s.wsState === 1).length
+  const connected = stations.filter(s => s.wsState === WebSocketReadyState.OPEN).length
   process.stdout.write(
     chalk.dim(
       `\n${stations.length.toString()} station${stations.length !== 1 ? 's' : ''} (${started.toString()} started, ${connected.toString()} connected)\n`
index d9a4c7fe352763e9d357026bfa752a018e9739e1..fd67cef43ae4e447fe217fe310f36f3f73f75b4d 100644 (file)
@@ -8,6 +8,7 @@ export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000
 export const EMPTY_VALUE_PLACEHOLDER = 'Ø'
 export const MAX_SKIN_ERROR_RELOADS = 2
 export const MAX_STATIONS_PER_ADD = 100
+export const SKIN_ERROR_RELOAD_COUNT_KEY = 'skin-error-reload-count'
 export const WH_PER_KWH = 1000
 
 export const ROUTE_NAMES = {
index f1f68c65064841b248b048a78d87592e0e3d0d9c..1072c4945366d3f675c06d1866f590eacbac78c0 100644 (file)
@@ -8,6 +8,7 @@ export {
   MAX_STATIONS_PER_ADD,
   ROUTE_NAMES,
   SHARED_TOGGLE_BUTTON_KEY_PREFIX,
+  SKIN_ERROR_RELOAD_COUNT_KEY,
   TOGGLE_BUTTON_KEY_PREFIX,
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   WH_PER_KWH,
index a60ebf80c8ec92e601ec01cb7a8c3e2d4b22bd5f..b4cbccba480d95b71b2658bf2c63b54d2e0d89a2 100644 (file)
 </template>
 
 <script setup lang="ts">
-import { DEFAULT_SKIN, MAX_SKIN_ERROR_RELOADS, setToLocalStorage } from '@/core/index.js'
+import {
+  DEFAULT_SKIN,
+  MAX_SKIN_ERROR_RELOADS,
+  setToLocalStorage,
+  SKIN_ERROR_RELOAD_COUNT_KEY,
+} from '@/core/index.js'
 import { SKIN_STORAGE_KEY } from '@/shared/composables/useSkin.js'
 // Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
 import { skins } from '@/skins/registry.js'
@@ -20,16 +25,11 @@ defineEmits<{ retry: [] }>()
 
 const defaultSkinLabel = skins.find(s => s.id === DEFAULT_SKIN)?.label ?? 'Default'
 
-/**
- * Resets to default skin with reload loop protection.
- * NOTE: Successful skin loads (e.g. in useSkin.switchSkin) should clear
- * the 'skin-error-reload-count' sessionStorage key to reset the counter.
- */
+/** Resets to default skin with reload loop protection. Counter is reset by `useSkin.switchSkin` on successful load. */
 function resetToDefault (): void {
-  const RELOAD_KEY = 'skin-error-reload-count'
   let count = 0
   try {
-    count = Number(sessionStorage.getItem(RELOAD_KEY) ?? '0')
+    count = Number(sessionStorage.getItem(SKIN_ERROR_RELOAD_COUNT_KEY) ?? '0')
   } catch {
     // sessionStorage unavailable (e.g. Safari private browsing)
   }
@@ -38,7 +38,7 @@ function resetToDefault (): void {
     return
   }
   try {
-    sessionStorage.setItem(RELOAD_KEY, String(count + 1))
+    sessionStorage.setItem(SKIN_ERROR_RELOAD_COUNT_KEY, String(count + 1))
   } catch {
     // sessionStorage unavailable — proceed with reset anyway
   }
index c299fe7daf575da53f28874d183509dd24bce67e..03e84cc873652b7c46b87da231a78d38cdeb3ac4 100644 (file)
@@ -3,6 +3,7 @@ import { type DeepReadonly, readonly, ref, type Ref, watch } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
 import { useTemplates, useUIClient } from '@/core/index.js'
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
 
 export interface AddStationsFormState {
   autoStart: boolean
@@ -70,14 +71,17 @@ export function useAddStationsForm (options?: { onFinally?: () => void }): {
         formState.value.numberOfStations,
         {
           autoStart: formState.value.autoStart,
-          baseName: nonEmpty(formState.value.baseName),
+          baseName: nonEmptyStringOrUndefined(formState.value.baseName),
           enableStatistics: formState.value.enableStatistics,
-          fixedName: formState.value.baseName.length > 0 ? formState.value.fixedName : undefined,
+          fixedName:
+            nonEmptyStringOrUndefined(formState.value.baseName) != null
+              ? formState.value.fixedName
+              : undefined,
           ocppStrictCompliance: formState.value.ocppStrictCompliance,
           persistentConfiguration: formState.value.persistentConfiguration,
-          supervisionPassword: nonEmpty(formState.value.supervisionPassword),
-          supervisionUrls: nonEmpty(formState.value.supervisionUrl),
-          supervisionUser: nonEmpty(formState.value.supervisionUser),
+          supervisionPassword: nonEmptyStringOrUndefined(formState.value.supervisionPassword),
+          supervisionUrls: nonEmptyStringOrUndefined(formState.value.supervisionUrl),
+          supervisionUser: nonEmptyStringOrUndefined(formState.value.supervisionUser),
         }
       )
       $toast.success('Charging stations successfully added')
@@ -124,15 +128,6 @@ function makeInitialState (): AddStationsFormState {
   }
 }
 
-/**
- * Returns `value` when it is non-empty, otherwise `undefined`.
- * @param value - The string to test.
- * @returns The original string, or `undefined` if it is empty.
- */
-function nonEmpty (value: string): string | undefined {
-  return value.length > 0 ? value : undefined
-}
-
 /**
  * Returns `true` when both arrays have identical length and values in the same order.
  * @param a - The incoming template list.
index 0d8a9f0d48bfee172bebe5de91c4557d953520c1..eb281ee3bc4e50c681e233bc490445bcabee070b 100644 (file)
@@ -1,7 +1,12 @@
 import { type SKIN_IDS } from 'ui-common'
 import { readonly, ref, type Ref } from 'vue'
 
-import { DEFAULT_SKIN, getFromLocalStorage, setToLocalStorage } from '@/core/index.js'
+import {
+  DEFAULT_SKIN,
+  getFromLocalStorage,
+  setToLocalStorage,
+  SKIN_ERROR_RELOAD_COUNT_KEY,
+} from '@/core/index.js'
 import { validateTokenContract } from '@/shared/tokens/contract.js'
 // Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling.
 import { type SkinDefinition, skins } from '@/skins/registry.js'
@@ -110,7 +115,7 @@ async function performSkinSwitch (skinId: string): Promise<boolean> {
     try {
       await loadSkinStyles(skinId)
       try {
-        sessionStorage.removeItem('skin-error-reload-count')
+        sessionStorage.removeItem(SKIN_ERROR_RELOAD_COUNT_KEY)
       } catch {
         /* sessionStorage unavailable */
       }
@@ -129,7 +134,7 @@ async function performSkinSwitch (skinId: string): Promise<boolean> {
     }
     setToLocalStorage<string>(SKIN_STORAGE_KEY, skinId)
     try {
-      sessionStorage.removeItem('skin-error-reload-count')
+      sessionStorage.removeItem(SKIN_ERROR_RELOAD_COUNT_KEY)
     } catch {
       /* sessionStorage unavailable */
     }
index 7d5784eb84e1271d46f7e2da9b5ac3348e02b6e9..39bb47b68bd0f2c1b3ca3f26df84694f4e2c6e8f 100644 (file)
@@ -3,6 +3,7 @@ import { type MaybeRef, readonly, ref, type Ref, toValue } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
 import { useUIClient } from '@/core/index.js'
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
 
 export interface StartTxFormConfig {
   connectorId: string
@@ -58,7 +59,7 @@ export function useStartTxForm (config: StartTxFormConfig): {
     if (pending.value) return false
     pending.value = true
     try {
-      const idTag = formState.value.idTag.length > 0 ? formState.value.idTag : undefined
+      const idTag = nonEmptyStringOrUndefined(formState.value.idTag)
 
       if (formState.value.authorizeIdTag) {
         if (idTag == null) {
index dbe3df0fbb95f0c51a29a9c6881a438f5ae06472..c2d4ea4d677e6c5b2fd9e36f75a3dacc8acd6803 100644 (file)
@@ -1,5 +1,6 @@
 export { getSelectValue } from './dom.js'
 export { formatSupervisionUrl } from './formatSupervisionUrl.js'
+export { nonEmptyStringOrUndefined } from './nonEmptyString.js'
 export type { StatusVariant } from './stationStatus.js'
 export {
   getATGStatus,
diff --git a/ui/web/src/shared/utils/nonEmptyString.ts b/ui/web/src/shared/utils/nonEmptyString.ts
new file mode 100644 (file)
index 0000000..203422c
--- /dev/null
@@ -0,0 +1,8 @@
+/**
+ * Returns `value` when it is a non-empty string, otherwise `undefined`.
+ * Accepts `unknown` so it can double as a type guard for object-property lookups.
+ * @param value - The candidate value.
+ * @returns The original string when non-empty, otherwise `undefined`.
+ */
+export const nonEmptyStringOrUndefined = (value: unknown): string | undefined =>
+  typeof value === 'string' && value.length > 0 ? value : undefined
index aa5014d63fd6036e2b7caaf088e7114d4a2739fa..ae8479fc88167581ae5a94add3cb23c40736cad5 100644 (file)
@@ -59,33 +59,34 @@ export function getConnectorEntries (station: ChargingStationData): ConnectorEnt
     }))
 }
 
+// cspell:ignore suspendedev suspendedevse
+const CONNECTOR_STATUS_VARIANT: Readonly<Record<string, StatusVariant>> = Object.freeze({
+  available: 'ok',
+  charging: 'warn',
+  faulted: 'err',
+  finishing: 'warn',
+  occupied: 'warn',
+  preparing: 'warn',
+  suspendedev: 'warn',
+  suspendedevse: 'warn',
+  unavailable: 'err',
+})
+
 /**
  * Maps an OCPP connector status string to a display variant.
  * @param status - The OCPP connector status value
  * @returns The display variant for the status
  */
 export function getConnectorStatusVariant (status?: string): StatusVariant {
-  // cspell:ignore suspendedev suspendedevse
-  switch (status?.toLowerCase()) {
-    case 'available':
-      return 'ok'
-    // Active use states: amber to distinguish from 'available' (green)
-    case 'charging':
-    case 'occupied':
-      return 'warn'
-    case 'faulted':
-    case 'unavailable':
-      return 'err'
-    case 'finishing':
-    case 'preparing':
-    case 'suspendedev':
-    case 'suspendedevse':
-      return 'warn'
-    default:
-      return 'idle'
-  }
+  if (status == null) return 'idle'
+  return CONNECTOR_STATUS_VARIANT[status.toLowerCase()] ?? 'idle'
 }
 
+const WS_STATE_CLOSED = 3
+const WS_STATE_CLOSING = 2
+const WS_STATE_CONNECTING = 0
+const WS_STATE_OPEN = 1
+
 /**
  * Maps a WebSocket ready state to a display variant.
  * @param wsState - The WebSocket readyState value
@@ -93,14 +94,14 @@ export function getConnectorStatusVariant (status?: string): StatusVariant {
  */
 export function getWebSocketStateVariant (wsState?: number): StatusVariant {
   switch (wsState) {
-    case 0: // WebSocket.CONNECTING
+    case WS_STATE_CLOSED:
+      return 'err'
+    case WS_STATE_CLOSING:
       return 'warn'
-    case 1: // WebSocket.OPEN
-      return 'ok'
-    case 2: // WebSocket.CLOSING
+    case WS_STATE_CONNECTING:
       return 'warn'
-    case 3: // WebSocket.CLOSED
-      return 'err'
+    case WS_STATE_OPEN:
+      return 'ok'
     default:
       return 'idle'
   }
index e325bb13ed100b9b39a0c3e5f2fd04898051fa82..b9140d6c392200413bfaacc24660769d0179cdd6 100644 (file)
@@ -115,7 +115,7 @@ import { useLayoutData } from '@/shared/composables/useLayoutData.js'
 import { useSimulatorControl } from '@/shared/composables/useSimulatorControl.js'
 import { useSkin } from '@/shared/composables/useSkin.js'
 import { type ThemeName, useTheme } from '@/shared/composables/useTheme.js'
-import { getSelectValue } from '@/shared/utils/dom.js'
+import { getSelectValue } from '@/shared/utils/index.js'
 
 import StateButton from './components/buttons/StateButton.vue'
 import ToggleButton from './components/buttons/ToggleButton.vue'
index 3913e25ac94e73bb98b2453dbd4c34fc12ff7501..cdd05664b540748aa584fb57294537ec3854516b 100644 (file)
@@ -130,8 +130,7 @@ import {
   ROUTE_NAMES,
 } from '@/core/index.js'
 import { useStationActions } from '@/shared/composables/useStationActions.js'
-import { formatSupervisionUrl } from '@/shared/utils/formatSupervisionUrl.js'
-import { getATGStatus, getConnectorEntries } from '@/shared/utils/stationStatus.js'
+import { formatSupervisionUrl, getATGStatus, getConnectorEntries } from '@/shared/utils/index.js'
 
 import Button from '../buttons/ClassicButton.vue'
 import StateButton from '../buttons/StateButton.vue'
index a10163e319f42e7d2418f05b57fae292b0ff2a20..8579309fa58c3b3fce8cefaa78b8e636f0e0c765 100644 (file)
@@ -193,7 +193,7 @@ import { computed, ref } from 'vue'
 
 import { WH_PER_KWH } from '@/core/index.js'
 import { useConnectorActions } from '@/shared/composables/useConnectorActions.js'
-import { getConnectorStatusVariant } from '@/shared/utils/stationStatus.js'
+import { getConnectorStatusVariant } from '@/shared/utils/index.js'
 
 import ActionButton from './ActionButton.vue'
 import SetConnectorStatusDialog from './dialogs/SetConnectorStatusDialog.vue'
index 158a88aacbb4f989f86f05893dc40e81565205da..e11a378c8e80ef60fd2c3b3cf475ee88db886a71 100644 (file)
@@ -90,7 +90,7 @@ import { computed } from 'vue'
 
 import { useSkin } from '@/shared/composables/useSkin.js'
 import { type ThemeName, useTheme } from '@/shared/composables/useTheme.js'
-import { getSelectValue } from '@/shared/utils/dom.js'
+import { getSelectValue } from '@/shared/utils/index.js'
 
 import ActionButton from './ActionButton.vue'
 import StatePill from './StatePill.vue'
index 88c37bb2df9f83620c80d6a900fe0756d9fd2d18..48ce8b7832e6799045d35e3b4f8a8a87e5bc7945 100644 (file)
@@ -169,12 +169,12 @@ import { computed, ref } from 'vue'
 
 import { deleteLocalStorageByKeyPattern, EMPTY_VALUE_PLACEHOLDER as EMPTY } from '@/core/index.js'
 import { useStationActions } from '@/shared/composables/useStationActions.js'
-import { formatSupervisionUrl } from '@/shared/utils/formatSupervisionUrl.js'
 import {
+  formatSupervisionUrl,
   getATGStatus,
   getConnectorEntries,
   getWebSocketStateVariant,
-} from '@/shared/utils/stationStatus.js'
+} from '@/shared/utils/index.js'
 
 import ActionButton from './ActionButton.vue'
 import ConfirmDialog from './ConfirmDialog.vue'
index e173305fc49518a0593dd47eb359c69990f16406..4c0252d9cd10f5df99f0b8f56bc0b529562d94d0 100644 (file)
@@ -85,7 +85,7 @@ import { computed, ref, watch } from 'vue'
 
 import { useChargingStations, useUIClient } from '@/core/index.js'
 import { useSetUrlForm } from '@/shared/composables/useSetUrlForm.js'
-import { stripStationId } from '@/shared/utils/stripStationId.js'
+import { stripStationId } from '@/shared/utils/index.js'
 
 import ActionButton from '../ActionButton.vue'
 import Modal from '../ModernModal.vue'
index 65bc18292b4da99baca43f383979975f2c4372df..c0885ced940a327c3b2e7c885ecb2b4565a1e7d3 100644 (file)
@@ -2,6 +2,8 @@
 
 import { extractErrorMessage, type ResponsePayload, ServerFailureError } from 'ui-common'
 
+import { nonEmptyStringOrUndefined } from '@/shared/utils/index.js'
+
 export interface FailureInfo {
   payload?: ResponsePayload
   summary: string
@@ -10,10 +12,8 @@ export interface FailureInfo {
 const asRecord = (value: unknown): Record<string, unknown> | undefined =>
   typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
 
-const stringField = (rec: Record<string, unknown> | undefined, key: string): string | undefined => {
-  const v = rec?.[key]
-  return typeof v === 'string' && v.length > 0 ? v : undefined
-}
+const stringField = (rec: Record<string, unknown> | undefined, key: string): string | undefined =>
+  nonEmptyStringOrUndefined(rec?.[key])
 
 export const getFailureInfo = (error: unknown): FailureInfo => {
   if (error instanceof ServerFailureError) {