From 9265dcf3333425a22b09894bb0ec78ed940403cf Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 6 Mar 2026 23:05:35 +0100 Subject: [PATCH] =?utf8?q?test(utils):=20audit=20=E2=80=94=20overhaul=20ex?= =?utf8?q?isting,=20add=203=20new=20test=20files,=20data-driven=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- tests/utils/AsyncLock.test.ts | 15 +- .../ChargingStationConfigurationUtils.test.ts | 301 ++++++++++++++++++ tests/utils/Configuration.test.ts | 34 +- tests/utils/ConfigurationUtils.test.ts | 15 +- tests/utils/ElectricUtils.test.ts | 38 +++ tests/utils/ErrorUtils.test.ts | 133 ++++++-- tests/utils/FileUtils.test.ts | 95 ++++++ tests/utils/MessageChannelUtils.test.ts | 212 ++++++++++++ tests/utils/Utils.test.ts | 225 ++++++++++--- 9 files changed, 970 insertions(+), 98 deletions(-) create mode 100644 tests/utils/ChargingStationConfigurationUtils.test.ts create mode 100644 tests/utils/FileUtils.test.ts create mode 100644 tests/utils/MessageChannelUtils.test.ts diff --git a/tests/utils/AsyncLock.test.ts b/tests/utils/AsyncLock.test.ts index 41cf1f20..cabd1b2f 100644 --- a/tests/utils/AsyncLock.test.ts +++ b/tests/utils/AsyncLock.test.ts @@ -3,7 +3,6 @@ * @description Unit tests for asynchronous lock utilities */ import { expect } from '@std/expect' -import { randomInt } from 'node:crypto' import { afterEach, describe, it } from 'node:test' import { AsyncLock, AsyncLockType } from '../../src/utils/AsyncLock.js' @@ -34,8 +33,9 @@ await describe('AsyncLock', async () => { const executed: number[] = [] let count = 0 const asyncFn = async () => { - await new Promise(resolve => { - setTimeout(resolve, randomInt(1, 101)) + // Yield to event loop without real timers + await new Promise(resolve => { + queueMicrotask(resolve) }) executed.push(++count) } @@ -72,16 +72,19 @@ await describe('AsyncLock', async () => { await it('should isolate locks across different lock types', async () => { const order: string[] = [] + let resolveConfig!: () => void const configPromise = AsyncLock.runExclusive(AsyncLockType.configuration, async () => { - await new Promise(resolve => { - setTimeout(resolve, 50) + await new Promise(resolve => { + resolveConfig = resolve }) order.push('configuration') }) const perfPromise = AsyncLock.runExclusive(AsyncLockType.performance, () => { order.push('performance') }) - await Promise.all([configPromise, perfPromise]) + await perfPromise + resolveConfig() + await configPromise expect(order[0]).toBe('performance') expect(order[1]).toBe('configuration') }) diff --git a/tests/utils/ChargingStationConfigurationUtils.test.ts b/tests/utils/ChargingStationConfigurationUtils.test.ts new file mode 100644 index 00000000..5772acd7 --- /dev/null +++ b/tests/utils/ChargingStationConfigurationUtils.test.ts @@ -0,0 +1,301 @@ +/** + * @file Tests for ChargingStationConfigurationUtils + * @description Unit tests for charging station configuration utility functions including + * buildConnectorsStatus, buildEvsesStatus, buildChargingStationAutomaticTransactionGeneratorConfiguration, + * and the OutputFormat enum. + */ +import { expect } from '@std/expect' +import { afterEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import type { ConnectorStatus, EvseStatus } from '../../src/types/index.js' + +import { AvailabilityType } from '../../src/types/index.js' +import { + buildChargingStationAutomaticTransactionGeneratorConfiguration, + buildConnectorsStatus, + buildEvsesStatus, + OutputFormat, +} from '../../src/utils/ChargingStationConfigurationUtils.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +/** + * Creates a minimal mock ChargingStation for configuration utility tests. + * @param options - Mock station properties. + * @param options.automaticTransactionGenerator - ATG instance stub. + * @param options.connectors - Connectors map. + * @param options.evses - EVSEs map. + * @param options.getAutomaticTransactionGeneratorConfiguration - ATG config getter. + * @returns Partial ChargingStation cast for test use. + */ +function createMockStationForConfigUtils (options: { + automaticTransactionGenerator?: undefined | { connectorsStatus?: Map } + connectors?: Map + evses?: Map + getAutomaticTransactionGeneratorConfiguration?: () => unknown +}): ChargingStation { + return { + automaticTransactionGenerator: options.automaticTransactionGenerator ?? undefined, + connectors: options.connectors ?? new Map(), + evses: options.evses ?? new Map(), + getAutomaticTransactionGeneratorConfiguration: + options.getAutomaticTransactionGeneratorConfiguration ?? (() => undefined), + } as unknown as ChargingStation +} + +await describe('ChargingStationConfigurationUtils', async () => { + afterEach(() => { + standardCleanup() + }) + + await describe('OutputFormat', async () => { + await it('should have correct enum values', () => { + expect(OutputFormat.configuration).toBe('configuration') + expect(OutputFormat.worker).toBe('worker') + }) + }) + + await describe('buildConnectorsStatus', async () => { + await it('should strip internal transaction fields from connectors', () => { + const noop = (): void => { + /* noop */ + } + const interval1 = setInterval(noop, 1000) + const interval2 = setInterval(noop, 1000) + try { + const connectors = new Map() + connectors.set(0, { + availability: AvailabilityType.Operative, + MeterValues: [], + } as ConnectorStatus) + connectors.set(1, { + availability: AvailabilityType.Operative, + bootStatus: 'Available', + MeterValues: [], + transactionEventQueue: [], + transactionSetInterval: interval1 as unknown as NodeJS.Timeout, + transactionTxUpdatedSetInterval: interval2 as unknown as NodeJS.Timeout, + } as unknown as ConnectorStatus) + + const station = createMockStationForConfigUtils({ connectors }) + const result = buildConnectorsStatus(station) + + expect(result.length).toBe(2) + for (const connector of result) { + expect('transactionSetInterval' in connector).toBe(false) + expect('transactionEventQueue' in connector).toBe(false) + expect('transactionTxUpdatedSetInterval' in connector).toBe(false) + } + expect(result[1].availability).toBe(AvailabilityType.Operative) + } finally { + clearInterval(interval1) + clearInterval(interval2) + } + }) + + await it('should handle empty connectors map', () => { + const station = createMockStationForConfigUtils({ connectors: new Map() }) + const result = buildConnectorsStatus(station) + expect(result.length).toBe(0) + }) + + await it('should preserve non-internal fields', () => { + const connectors = new Map() + connectors.set(1, { + availability: AvailabilityType.Operative, + bootStatus: 'Available', + MeterValues: [], + transactionEventQueue: undefined, + transactionId: 42, + transactionSetInterval: undefined, + transactionStarted: true, + transactionTxUpdatedSetInterval: undefined, + } as unknown as ConnectorStatus) + + const station = createMockStationForConfigUtils({ connectors }) + const result = buildConnectorsStatus(station) + + expect(result.length).toBe(1) + expect(result[0].availability).toBe(AvailabilityType.Operative) + expect(result[0].transactionId).toBe(42) + expect(result[0].transactionStarted).toBe(true) + }) + }) + + await describe('buildEvsesStatus', async () => { + await it('should return configuration format with connectorsStatus and without connectors', () => { + const evseConnectors = new Map() + evseConnectors.set(1, { + availability: AvailabilityType.Operative, + MeterValues: [], + transactionEventQueue: [], + transactionSetInterval: undefined, + transactionTxUpdatedSetInterval: undefined, + } as unknown as ConnectorStatus) + + const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station, OutputFormat.configuration) + + expect(result.length).toBe(2) + const evse1 = result[1] as Record + expect('connectorsStatus' in evse1).toBe(true) + expect('connectors' in evse1).toBe(false) + }) + + await it('should strip internal fields from evse connectors in configuration format', () => { + const evseConnectors = new Map() + evseConnectors.set(1, { + availability: AvailabilityType.Operative, + MeterValues: [], + transactionEventQueue: [], + transactionSetInterval: undefined, + transactionTxUpdatedSetInterval: undefined, + } as unknown as ConnectorStatus) + + const evses = new Map() + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station, OutputFormat.configuration) + const evse1 = result[0] as Record + const connectorsStatus = evse1.connectorsStatus as ConnectorStatus[] + + expect(connectorsStatus.length).toBe(1) + expect('transactionSetInterval' in connectorsStatus[0]).toBe(false) + expect('transactionEventQueue' in connectorsStatus[0]).toBe(false) + expect('transactionTxUpdatedSetInterval' in connectorsStatus[0]).toBe(false) + }) + + await it('should return worker format with connectors array', () => { + const evseConnectors = new Map() + evseConnectors.set(1, { + availability: AvailabilityType.Operative, + MeterValues: [], + transactionEventQueue: undefined, + transactionSetInterval: undefined, + transactionTxUpdatedSetInterval: undefined, + } as unknown as ConnectorStatus) + + const evses = new Map() + evses.set(0, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: evseConnectors, + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station, OutputFormat.worker) + + expect(result.length).toBe(2) + const evse1 = result[1] as Record + expect('connectors' in evse1).toBe(true) + expect(Array.isArray(evse1.connectors)).toBe(true) + }) + + await it('should default to configuration format when no format specified', () => { + const evses = new Map() + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + + const station = createMockStationForConfigUtils({ evses }) + const result = buildEvsesStatus(station) + + expect(result.length).toBe(1) + const evse = result[0] as Record + expect('connectorsStatus' in evse).toBe(true) + expect('connectors' in evse).toBe(false) + }) + + await it('should handle empty evses map', () => { + const station = createMockStationForConfigUtils({ evses: new Map() }) + const result = buildEvsesStatus(station, OutputFormat.configuration) + expect(result.length).toBe(0) + }) + + await it('should throw RangeError for unknown output format', () => { + const evses = new Map() + evses.set(1, { + availability: AvailabilityType.Operative, + connectors: new Map(), + }) + const station = createMockStationForConfigUtils({ evses }) + + expect(() => { + buildEvsesStatus(station, 'unknown' as OutputFormat) + }).toThrow(RangeError) + }) + }) + + await describe('buildChargingStationAutomaticTransactionGeneratorConfiguration', async () => { + await it('should return ATG configuration when present', () => { + const atgConfig = { enable: true, maxDuration: 120, minDuration: 60 } + const station = createMockStationForConfigUtils({ + automaticTransactionGenerator: { + connectorsStatus: new Map([[1, { start: false }]]), + }, + getAutomaticTransactionGeneratorConfiguration: () => atgConfig, + }) + const result = buildChargingStationAutomaticTransactionGeneratorConfiguration(station) + + expect(result.automaticTransactionGenerator).toStrictEqual(atgConfig) + expect(result.automaticTransactionGeneratorStatuses).toBeDefined() + expect(Array.isArray(result.automaticTransactionGeneratorStatuses)).toBe(true) + expect(result.automaticTransactionGeneratorStatuses?.length).toBe(1) + }) + + await it('should return ATG configuration without statuses when no ATG instance', () => { + const atgConfig = { enable: false } + const station = createMockStationForConfigUtils({ + automaticTransactionGenerator: undefined, + getAutomaticTransactionGeneratorConfiguration: () => atgConfig, + }) + const result = buildChargingStationAutomaticTransactionGeneratorConfiguration(station) + + expect(result.automaticTransactionGenerator).toStrictEqual(atgConfig) + expect(result.automaticTransactionGeneratorStatuses).toBeUndefined() + }) + + await it('should return undefined ATG config when not configured', () => { + const station = createMockStationForConfigUtils({ + automaticTransactionGenerator: undefined, + getAutomaticTransactionGeneratorConfiguration: () => undefined, + }) + const result = buildChargingStationAutomaticTransactionGeneratorConfiguration(station) + + expect(result.automaticTransactionGenerator).toBeUndefined() + expect(result.automaticTransactionGeneratorStatuses).toBeUndefined() + }) + + await it('should return ATG configuration without statuses when connectorsStatus is null', () => { + const atgConfig = { enable: true } + const station = createMockStationForConfigUtils({ + automaticTransactionGenerator: { + connectorsStatus: undefined, + }, + getAutomaticTransactionGeneratorConfiguration: () => atgConfig, + }) + const result = buildChargingStationAutomaticTransactionGeneratorConfiguration(station) + + expect(result.automaticTransactionGenerator).toStrictEqual(atgConfig) + expect(result.automaticTransactionGeneratorStatuses).toBeUndefined() + }) + }) +}) diff --git a/tests/utils/Configuration.test.ts b/tests/utils/Configuration.test.ts index 9f071f5e..aba888a7 100644 --- a/tests/utils/Configuration.test.ts +++ b/tests/utils/Configuration.test.ts @@ -21,7 +21,13 @@ import type { WorkerConfiguration, } from '../../src/types/index.js' -import { ConfigurationSection, SupervisionUrlDistribution } from '../../src/types/index.js' +import { + ApplicationProtocol, + ApplicationProtocolVersion, + ConfigurationSection, + StorageType, + SupervisionUrlDistribution, +} from '../../src/types/index.js' import { Configuration } from '../../src/utils/index.js' import { WorkerProcessType } from '../../src/worker/WorkerTypes.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' @@ -88,11 +94,11 @@ await describe('Configuration', async () => { ConfigurationSection.worker ) expect(worker).toBeDefined() - expect(worker.processType).toBeDefined() - expect(worker.startDelay).toBeDefined() - expect(worker.poolMinSize).toBeDefined() - expect(worker.poolMaxSize).toBeDefined() - expect(worker.elementsPerWorker).toBeDefined() + expect(worker.processType).toBe(WorkerProcessType.workerSet) + expect(worker.startDelay).toBe(500) + expect(typeof worker.poolMinSize).toBe('number') + expect(typeof worker.poolMaxSize).toBe('number') + expect(worker.elementsPerWorker).toBe('auto') }) await it('should include default worker process type', () => { @@ -107,10 +113,12 @@ await describe('Configuration', async () => { ConfigurationSection.uiServer ) expect(uiServer).toBeDefined() - expect(typeof uiServer.enabled).toBe('boolean') - expect(uiServer.type).toBeDefined() - expect(uiServer.version).toBeDefined() + expect(uiServer.enabled).toBe(false) + expect(uiServer.type).toBe(ApplicationProtocol.WS) + expect(uiServer.version).toBe(ApplicationProtocolVersion.VERSION_11) expect(uiServer.options).toBeDefined() + expect(typeof uiServer.options?.host).toBe('string') + expect(typeof uiServer.options?.port).toBe('number') }) await it('should return performance storage configuration', () => { @@ -118,8 +126,8 @@ await describe('Configuration', async () => { ConfigurationSection.performanceStorage ) expect(storage).toBeDefined() - expect(typeof storage.enabled).toBe('boolean') - expect(storage.type).toBeDefined() + expect(storage.enabled).toBe(true) + expect(storage.type).toBe(StorageType.NONE) }) await it('should return station template URLs', () => { @@ -162,9 +170,7 @@ await describe('Configuration', async () => { await it('should return supervision URLs from configuration', () => { const urls = Configuration.getSupervisionUrls() - if (urls != null) { - expect(typeof urls === 'string' || Array.isArray(urls)).toBe(true) - } + expect(urls == null || typeof urls === 'string' || Array.isArray(urls)).toBe(true) }) await it('should throw for unknown configuration section', () => { diff --git a/tests/utils/ConfigurationUtils.test.ts b/tests/utils/ConfigurationUtils.test.ts index 983d87ea..e33f405d 100644 --- a/tests/utils/ConfigurationUtils.test.ts +++ b/tests/utils/ConfigurationUtils.test.ts @@ -5,14 +5,13 @@ import { expect } from '@std/expect' import { afterEach, describe, it } from 'node:test' -import { FileType, StorageType } from '../../src/types/index.js' +import { StorageType } from '../../src/types/index.js' import { buildPerformanceUriFilePath, checkWorkerElementsPerWorker, getDefaultPerformanceStorageUri, logPrefix, } from '../../src/utils/ConfigurationUtils.js' -import { handleFileException } from '../../src/utils/ErrorUtils.js' import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' await describe('ConfigurationUtils', async () => { @@ -47,18 +46,6 @@ await describe('ConfigurationUtils', async () => { }).toThrow(Error) }) - await it('should throw and log error for file exceptions', t => { - const mockConsoleError = t.mock.method(console, 'error') - const error = new Error() as NodeJS.ErrnoException - error.code = 'ENOENT' - expect(() => { - handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', { - consoleOut: true, - }) - }).toThrow(error) - expect(mockConsoleError.mock.calls.length).toBe(1) - }) - await it('should validate worker elements per worker configuration', () => { // These calls should not throw exceptions expect(() => { diff --git a/tests/utils/ElectricUtils.test.ts b/tests/utils/ElectricUtils.test.ts index ddbe29c7..3be19c3a 100644 --- a/tests/utils/ElectricUtils.test.ts +++ b/tests/utils/ElectricUtils.test.ts @@ -56,4 +56,42 @@ await describe('ElectricUtils', async () => { expect(DCElectricUtils.power(0, 10)).toBe(0) expect(DCElectricUtils.power(400, 0)).toBe(0) }) + + // Realistic EV charging scenarios + + await it('should calculate 7.4 kW single-phase AC home charger values', () => { + // 230V × 32A × 1 phase × cosPhi=1 = 7360W + expect(ACElectricUtils.powerPerPhase(230, 32)).toBe(7360) + expect(ACElectricUtils.powerTotal(1, 230, 32)).toBe(7360) + expect(ACElectricUtils.amperageTotalFromPower(7360, 230)).toBe(32) + expect(ACElectricUtils.amperagePerPhaseFromPower(1, 7360, 230)).toBe(32) + }) + + await it('should calculate 22 kW three-phase AC wall box values', () => { + // 230V × 32A × 3 phases × cosPhi=1 = 22080W + expect(ACElectricUtils.powerPerPhase(230, 32)).toBe(7360) + expect(ACElectricUtils.powerTotal(3, 230, 32)).toBe(22080) + expect(ACElectricUtils.amperageTotalFromPower(22080, 230)).toBe(96) + expect(ACElectricUtils.amperagePerPhaseFromPower(3, 22080, 230)).toBe(32) + }) + + await it('should calculate 50 kW DC fast charger values', () => { + expect(DCElectricUtils.power(400, 125)).toBe(50000) + expect(DCElectricUtils.amperage(50000, 400)).toBe(125) + }) + + await it('should calculate 150 kW DC high-power charger values', () => { + expect(DCElectricUtils.power(500, 300)).toBe(150000) + expect(DCElectricUtils.amperage(150000, 500)).toBe(300) + }) + + await it('should handle industrial cosPhi values for AC calculations', () => { + // cosPhi = 0.95 (typical industrial) + expect(ACElectricUtils.powerPerPhase(230, 32, 0.95)).toBe(6992) + expect(ACElectricUtils.powerTotal(3, 230, 32, 0.95)).toBe(20976) + expect(ACElectricUtils.amperageTotalFromPower(6992, 230, 0.95)).toBe(32) + // cosPhi = 0.9 + expect(ACElectricUtils.powerPerPhase(230, 32, 0.9)).toBe(6624) + expect(ACElectricUtils.amperageTotalFromPower(6624, 230, 0.9)).toBe(32) + }) }) diff --git a/tests/utils/ErrorUtils.test.ts b/tests/utils/ErrorUtils.test.ts index 95b2e577..83228f59 100644 --- a/tests/utils/ErrorUtils.test.ts +++ b/tests/utils/ErrorUtils.test.ts @@ -3,7 +3,10 @@ * @description Unit tests for error handling utilities */ import { expect } from '@std/expect' -import { afterEach, describe, it } from 'node:test' +import process from 'node:process' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' import { FileType, @@ -16,56 +19,124 @@ import { handleFileException, handleIncomingRequestError, handleSendMessageError, + handleUncaughtException, + handleUnhandledRejection, } from '../../src/utils/ErrorUtils.js' import { logger } from '../../src/utils/Logger.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../charging-station/ChargingStationTestConstants.js' import { createMockChargingStation } from '../charging-station/ChargingStationTestUtils.js' -import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' +import { + createConsoleMocks, + createLoggerMocks, + standardCleanup, +} from '../helpers/TestLifecycleHelpers.js' await describe('ErrorUtils', async () => { - const { station: chargingStation } = createMockChargingStation({ - baseName: TEST_CHARGING_STATION_BASE_NAME, + let chargingStation: ChargingStation + + beforeEach(() => { + const { station } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + }) + chargingStation = station }) afterEach(() => { standardCleanup() }) - await it('should throw or warn based on error code and options', t => { - const consoleWarnMock = t.mock.method(console, 'warn') - const consoleErrorMock = t.mock.method(console, 'error') - const warnMock = t.mock.method(logger, 'warn') - const errorMock = t.mock.method(logger, 'error') + await it('should throw error with logger output when throwError is true', t => { + const { errorMock } = createLoggerMocks(t, logger) const error = new Error() as NodeJS.ErrnoException error.code = 'ENOENT' expect(() => { handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', {}) }).toThrow(error) + expect(errorMock.mock.calls.length).toBe(1) + }) + + await it('should log warning with logger when throwError is false', t => { + const { warnMock } = createLoggerMocks(t, logger) + const error = new Error() as NodeJS.ErrnoException + error.code = 'ENOENT' expect(() => { handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', { throwError: false, }) }).not.toThrow() expect(warnMock.mock.calls.length).toBe(1) - expect(errorMock.mock.calls.length).toBe(1) + }) + + await it('should throw error with console output when consoleOut is true', t => { + const { errorMock } = createConsoleMocks(t, { error: true }) + const error = new Error() as NodeJS.ErrnoException + error.code = 'ENOENT' expect(() => { handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', { consoleOut: true, }) }).toThrow(error) + expect(errorMock?.mock.calls.length).toBe(1) + }) + + await it('should log console warning when consoleOut and throwError are false', t => { + const { warnMock } = createConsoleMocks(t, { error: true, warn: true }) + const error = new Error() as NodeJS.ErrnoException + error.code = 'ENOENT' expect(() => { handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', { consoleOut: true, throwError: false, }) }).not.toThrow() - expect(consoleWarnMock.mock.calls.length).toBe(1) - expect(consoleErrorMock.mock.calls.length).toBe(1) + expect(warnMock?.mock.calls.length).toBe(1) }) - await it('should log error and optionally throw for send message errors', t => { - const errorMock = t.mock.method(logger, 'error') - const logPrefixMock = t.mock.method(chargingStation, 'logPrefix') + await it('should produce correct log message for each error code', t => { + const { warnMock } = createLoggerMocks(t, logger) + const errorCodes = [ + { code: 'ENOENT', expectedSubstring: 'not found' }, + { code: 'EACCES', expectedSubstring: 'access denied' }, + { code: 'EPERM', expectedSubstring: 'permission denied' }, + { code: 'EISDIR', expectedSubstring: 'is a directory' }, + { code: 'ENOSPC', expectedSubstring: 'no space left' }, + { code: 'ENOTDIR', expectedSubstring: 'not a directory' }, + { code: 'EEXIST', expectedSubstring: 'already exists' }, + { code: 'EROFS', expectedSubstring: 'read-only' }, + { code: 'UNKNOWN_CODE', expectedSubstring: 'error' }, + ] + for (const { code } of errorCodes) { + const error = new Error() as NodeJS.ErrnoException + error.code = code + handleFileException('path/to/module.js', FileType.Authorization, error, 'log prefix |', { + throwError: false, + }) + } + expect(warnMock.mock.calls.length).toBe(errorCodes.length) + for (let i = 0; i < errorCodes.length; i++) { + const call = warnMock.mock.calls[i] as unknown as { arguments: unknown[] } + const logMessage = String(call.arguments[0]).toLowerCase() + expect(logMessage.includes(errorCodes[i].expectedSubstring)).toBe(true) + } + }) + + await it('should register uncaught exception handler on process', t => { + const onMock = t.mock.method(process, 'on') + handleUncaughtException() + expect(onMock.mock.calls.length).toBe(1) + expect(onMock.mock.calls[0].arguments[0]).toBe('uncaughtException') + }) + + await it('should register unhandled rejection handler on process', t => { + const onMock = t.mock.method(process, 'on') + handleUnhandledRejection() + expect(onMock.mock.calls.length).toBe(1) + expect(onMock.mock.calls[0].arguments[0]).toBe('unhandledRejection') + }) + + await it('should log error and not throw for send message errors by default', t => { + const { errorMock } = createLoggerMocks(t, logger) + t.mock.method(chargingStation, 'logPrefix') const error = new Error() expect(() => { handleSendMessageError( @@ -75,6 +146,13 @@ await describe('ErrorUtils', async () => { error ) }).not.toThrow() + expect(errorMock.mock.calls.length).toBe(1) + }) + + await it('should throw for send message errors when throwError is true', t => { + const { errorMock } = createLoggerMocks(t, logger) + t.mock.method(chargingStation, 'logPrefix') + const error = new Error() expect(() => { handleSendMessageError( chargingStation, @@ -84,22 +162,34 @@ await describe('ErrorUtils', async () => { { throwError: true } ) }).toThrow(error) - expect(logPrefixMock.mock.calls.length).toBe(2) - expect(errorMock.mock.calls.length).toBe(2) + expect(errorMock.mock.calls.length).toBe(1) }) - await it('should log error and return error response for incoming requests', t => { - const errorMock = t.mock.method(logger, 'error') - const logPrefixMock = t.mock.method(chargingStation, 'logPrefix') + await it('should log error and not throw for incoming request errors by default', t => { + const { errorMock } = createLoggerMocks(t, logger) + t.mock.method(chargingStation, 'logPrefix') const error = new Error() expect(() => { handleIncomingRequestError(chargingStation, IncomingRequestCommand.CLEAR_CACHE, error) }).not.toThrow(error) + expect(errorMock.mock.calls.length).toBe(1) + }) + + await it('should throw for incoming request errors when throwError is true', t => { + createLoggerMocks(t, logger) + t.mock.method(chargingStation, 'logPrefix') + const error = new Error() expect(() => { handleIncomingRequestError(chargingStation, IncomingRequestCommand.CLEAR_CACHE, error, { throwError: true, }) }).toThrow() + }) + + await it('should return error response for incoming request errors', t => { + const { errorMock } = createLoggerMocks(t, logger) + t.mock.method(chargingStation, 'logPrefix') + const error = new Error() const errorResponse = { status: GenericStatus.Rejected, } @@ -108,7 +198,6 @@ await describe('ErrorUtils', async () => { errorResponse, }) ).toStrictEqual(errorResponse) - expect(logPrefixMock.mock.calls.length).toBe(3) - expect(errorMock.mock.calls.length).toBe(3) + expect(errorMock.mock.calls.length).toBe(1) }) }) diff --git a/tests/utils/FileUtils.test.ts b/tests/utils/FileUtils.test.ts new file mode 100644 index 00000000..e3fdfef9 --- /dev/null +++ b/tests/utils/FileUtils.test.ts @@ -0,0 +1,95 @@ +/** + * @file Tests for FileUtils + * @description Unit tests for file watching utility functions + */ +import { expect } from '@std/expect' +import { mkdtempSync, rmSync, type WatchListener, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, it } from 'node:test' + +import { FileType } from '../../src/types/index.js' +import { watchJsonFile } from '../../src/utils/FileUtils.js' +import { logger } from '../../src/utils/Logger.js' +import { createLoggerMocks, standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +const noop: WatchListener = () => { + /* intentionally empty */ +} + +await describe('FileUtils', async () => { + afterEach(() => { + standardCleanup() + }) + + await it('should return undefined and log info for empty file path', t => { + const infoMock = t.mock.method(logger, 'info') + + const result = watchJsonFile('', FileType.Authorization, 'test prefix |', noop) + + expect(result).toBeUndefined() + expect(infoMock.mock.calls.length).toBe(1) + }) + + await it('should include file type and log prefix in info log message for empty path', t => { + const infoMock = t.mock.method(logger, 'info') + + watchJsonFile('', FileType.ChargingStationConfiguration, 'CS-001 |', noop) + + expect(infoMock.mock.calls.length).toBe(1) + const logMessage = infoMock.mock.calls[0].arguments[0] as unknown as string + expect(logMessage.includes(FileType.ChargingStationConfiguration)).toBe(true) + expect(logMessage.includes('CS-001 |')).toBe(true) + }) + + await it('should handle watch error and return undefined for nonexistent file', t => { + const { warnMock } = createLoggerMocks(t, logger) + + const result = watchJsonFile( + '/nonexistent/path/to/file.json', + FileType.Authorization, + 'test prefix |', + noop + ) + + expect(result).toBeUndefined() + expect(warnMock.mock.calls.length).toBe(1) + }) + + await it('should return FSWatcher for valid file path', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'fileutils-test-')) + const tmpFile = join(tmpDir, 'test.json') + writeFileSync(tmpFile, '{}') + + try { + const result = watchJsonFile(tmpFile, FileType.Authorization, 'test |', noop) + + expect(result).toBeDefined() + result?.close() + } finally { + rmSync(tmpDir, { recursive: true }) + } + }) + + await it('should call watch with file and listener arguments', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'fileutils-test-')) + const tmpFile = join(tmpDir, 'test.json') + writeFileSync(tmpFile, '{}') + + try { + let receivedEvent = false + const listener: WatchListener = () => { + receivedEvent = true + } + + const result = watchJsonFile(tmpFile, FileType.Authorization, 'test |', listener) + + expect(result).toBeDefined() + expect(typeof result?.close).toBe('function') + result?.close() + expect(receivedEvent).toBe(false) + } finally { + rmSync(tmpDir, { recursive: true }) + } + }) +}) diff --git a/tests/utils/MessageChannelUtils.test.ts b/tests/utils/MessageChannelUtils.test.ts new file mode 100644 index 00000000..a5e02b7a --- /dev/null +++ b/tests/utils/MessageChannelUtils.test.ts @@ -0,0 +1,212 @@ +/** + * @file Tests for MessageChannelUtils + * @description Unit tests for charging station worker message builders and performance statistics conversion + */ +import { expect } from '@std/expect' +import { CircularBuffer } from 'mnemonist' +import { afterEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../src/charging-station/ChargingStation.js' +import type { Statistics, TimestampedData } from '../../src/types/index.js' + +import { ChargingStationWorkerMessageEvents } from '../../src/types/index.js' +import { + buildAddedMessage, + buildDeletedMessage, + buildPerformanceStatisticsMessage, + buildStartedMessage, + buildStoppedMessage, + buildUpdatedMessage, +} from '../../src/utils/MessageChannelUtils.js' +import { standardCleanup } from '../helpers/TestLifecycleHelpers.js' + +/** + * Creates a minimal mock station with properties needed by MessageChannelUtils builders. + * @returns Mock charging station instance + */ +function createMockStationForMessages (): ChargingStation { + return { + automaticTransactionGenerator: undefined, + bootNotificationResponse: { + currentTime: new Date('2024-01-01T00:00:00Z'), + interval: 300, + status: 'Accepted', + }, + connectors: new Map([ + [ + 0, + { + availability: 'Operative', + MeterValues: [], + }, + ], + [ + 1, + { + availability: 'Operative', + MeterValues: [], + }, + ], + ]), + evses: new Map(), + getAutomaticTransactionGeneratorConfiguration: () => undefined, + ocppConfiguration: { configurationKey: [] }, + started: true, + stationInfo: { + baseName: 'CS-TEST', + chargingStationId: 'CS-TEST-00001', + hashId: 'test-hash', + templateIndex: 1, + templateName: 'test-template.json', + }, + wsConnection: { readyState: 1 }, + wsConnectionUrl: new URL('ws://localhost:8080/CS-TEST-00001'), + } as unknown as ChargingStation +} + +await describe('MessageChannelUtils', async () => { + afterEach(() => { + standardCleanup() + }) + + await it('should build added message with correct event and data', () => { + const station = createMockStationForMessages() + const message = buildAddedMessage(station) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.added) + expect(message.data).toBeDefined() + expect(message.data.started).toBe(true) + expect(message.data.stationInfo.chargingStationId).toBe('CS-TEST-00001') + expect(message.data.supervisionUrl).toBe('ws://localhost:8080/CS-TEST-00001') + expect(typeof message.data.timestamp).toBe('number') + }) + + await it('should build deleted message with correct event', () => { + const station = createMockStationForMessages() + const message = buildDeletedMessage(station) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.deleted) + expect(message.data).toBeDefined() + expect(message.data.stationInfo.chargingStationId).toBe('CS-TEST-00001') + }) + + await it('should build started message with correct event', () => { + const station = createMockStationForMessages() + const message = buildStartedMessage(station) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.started) + expect(message.data.started).toBe(true) + }) + + await it('should build stopped message with correct event', () => { + const station = createMockStationForMessages() + const message = buildStoppedMessage(station) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.stopped) + expect(message.data).toBeDefined() + expect(message.data.supervisionUrl).toBe('ws://localhost:8080/CS-TEST-00001') + }) + + await it('should build updated message with correct event', () => { + const station = createMockStationForMessages() + const message = buildUpdatedMessage(station) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.updated) + expect(message.data).toBeDefined() + expect(message.data.stationInfo.chargingStationId).toBe('CS-TEST-00001') + }) + + await it('should include ws state in station messages', () => { + const station = createMockStationForMessages() + const message = buildAddedMessage(station) + + expect(message.data.wsState).toBe(1) + }) + + await it('should include connectors status in station messages', () => { + const station = createMockStationForMessages() + const message = buildAddedMessage(station) + + expect(Array.isArray(message.data.connectors)).toBe(true) + expect(message.data.connectors.length).toBe(2) + }) + + await it('should convert CircularBuffer to array in statistics data', () => { + const buffer = new CircularBuffer(Array, 10) + buffer.push({ timestamp: 1000, value: 42 }) + buffer.push({ timestamp: 2000, value: 84 }) + + const statistics: Statistics = { + createdAt: new Date('2024-01-01T00:00:00Z'), + id: 'test-station-id', + name: 'test-station', + statisticsData: new Map([ + [ + 'Heartbeat', + { + measurementTimeSeries: buffer, + requestCount: 5, + responseCount: 5, + }, + ], + ]), + uri: 'ws://localhost:8080', + } + + const message = buildPerformanceStatisticsMessage(statistics) + + expect(message.event).toBe(ChargingStationWorkerMessageEvents.performanceStatistics) + expect(message.data.id).toBe('test-station-id') + expect(message.data.name).toBe('test-station') + + const heartbeatStats = message.data.statisticsData.get('Heartbeat') + expect(heartbeatStats).toBeDefined() + expect(Array.isArray(heartbeatStats?.measurementTimeSeries)).toBe(true) + const timeSeries = heartbeatStats?.measurementTimeSeries as TimestampedData[] + expect(timeSeries.length).toBe(2) + expect(timeSeries[0].value).toBe(42) + expect(timeSeries[1].value).toBe(84) + }) + + await it('should preserve non-CircularBuffer measurement time series', () => { + const statistics: Statistics = { + createdAt: new Date('2024-01-01T00:00:00Z'), + id: 'test-id', + name: 'test-station', + statisticsData: new Map([ + [ + 'Heartbeat', + { + measurementTimeSeries: [{ timestamp: 1000, value: 10 }], + requestCount: 3, + }, + ], + ]), + uri: 'ws://localhost:8080', + } + + const message = buildPerformanceStatisticsMessage(statistics) + const heartbeat = message.data.statisticsData.get('Heartbeat') + expect(Array.isArray(heartbeat?.measurementTimeSeries)).toBe(true) + }) + + await it('should preserve statistics metadata in performance message', () => { + const createdAt = new Date('2024-01-01T00:00:00Z') + const updatedAt = new Date('2024-01-02T00:00:00Z') + + const statistics: Statistics = { + createdAt, + id: 'station-001', + name: 'station-name', + statisticsData: new Map(), + updatedAt, + uri: 'ws://localhost:8080', + } + + const message = buildPerformanceStatisticsMessage(statistics) + + expect(message.data.createdAt).toBe(createdAt) + expect(message.data.updatedAt).toBe(updatedAt) + expect(message.data.uri).toBe('ws://localhost:8080') + }) +}) diff --git a/tests/utils/Utils.test.ts b/tests/utils/Utils.test.ts index 38d0c691..ad95f566 100644 --- a/tests/utils/Utils.test.ts +++ b/tests/utils/Utils.test.ts @@ -6,6 +6,7 @@ import { expect } from '@std/expect' import { hoursToMilliseconds, hoursToSeconds } from 'date-fns' import { CircularBuffer } from 'mnemonist' import { randomInt } from 'node:crypto' +import process from 'node:process' import { version } from 'node:process' import { afterEach, describe, it } from 'node:test' import { satisfies } from 'semver' @@ -13,6 +14,7 @@ import { satisfies } from 'semver' import type { TimestampedData } from '../../src/types/index.js' import { JSRuntime, runtime } from '../../scripts/runtime.js' +import { MapStringifyFormat } from '../../src/types/index.js' import { Constants } from '../../src/utils/Constants.js' import { clampToSafeTimerValue, @@ -28,15 +30,23 @@ import { formatDurationSeconds, generateUUID, getRandomFloat, + getRandomFloatFluctuatedRounded, + getRandomFloatRounded, + getWebSocketCloseEventStatusString, has, insertAt, isArraySorted, isAsyncFunction, + isCFEnvironment, isEmpty, isNotEmptyArray, isNotEmptyString, isValidDate, + JSONStringify, + logPrefix, + mergeDeepRight, once, + queueMicrotaskErrorThrowing, roundTo, secureRandom, sleep, @@ -52,7 +62,7 @@ await describe('Utils', async () => { await it('should generate valid UUIDs and validate them correctly', () => { const uuid = generateUUID() expect(uuid).toBeDefined() - expect(uuid.length).toStrictEqual(36) + expect(uuid.length).toBe(36) expect(validateUUID(uuid)).toBe(true) expect(validateUUID('abcdef00-0000-4000-9000-000000000000')).toBe(true) expect(validateUUID('abcdef00-0000-4000-a000-000000000000')).toBe(true) @@ -142,7 +152,7 @@ await describe('Utils', async () => { expect(convertToInt(null)).toBe(0) expect(convertToInt(0)).toBe(0) const randomInteger = randomInt(Constants.MAX_RANDOM_INTEGER) - expect(convertToInt(randomInteger)).toStrictEqual(randomInteger) + expect(convertToInt(randomInteger)).toBe(randomInteger) expect(convertToInt('-1')).toBe(-1) expect(convertToInt('1')).toBe(1) expect(convertToInt('1.1')).toBe(1) @@ -163,7 +173,7 @@ await describe('Utils', async () => { expect(convertToFloat(null)).toBe(0) expect(convertToFloat(0)).toBe(0) const randomFloat = getRandomFloat() - expect(convertToFloat(randomFloat)).toStrictEqual(randomFloat) + expect(convertToFloat(randomFloat)).toBe(randomFloat) expect(convertToFloat('-1')).toBe(-1) expect(convertToFloat('1')).toBe(1) expect(convertToFloat('1.1')).toBe(1.1) @@ -250,47 +260,55 @@ await describe('Utils', async () => { }) await it('should correctly identify async functions from other types', () => { - expect(isAsyncFunction(null)).toBe(false) - expect(isAsyncFunction(undefined)).toBe(false) - expect(isAsyncFunction(true)).toBe(false) - expect(isAsyncFunction(false)).toBe(false) - expect(isAsyncFunction(0)).toBe(false) - expect(isAsyncFunction('')).toBe(false) - expect(isAsyncFunction([])).toBe(false) - expect(isAsyncFunction(new Date())).toBe(false) - expect(isAsyncFunction(/[a-z]/i)).toBe(false) - expect(isAsyncFunction(new Error())).toBe(false) - expect(isAsyncFunction(new Map())).toBe(false) - expect(isAsyncFunction(new Set())).toBe(false) - expect(isAsyncFunction(new WeakMap())).toBe(false) - expect(isAsyncFunction(new WeakSet())).toBe(false) - expect(isAsyncFunction(new Int8Array())).toBe(false) - expect(isAsyncFunction(new Uint8Array())).toBe(false) - expect(isAsyncFunction(new Uint8ClampedArray())).toBe(false) - expect(isAsyncFunction(new Int16Array())).toBe(false) - expect(isAsyncFunction(new Uint16Array())).toBe(false) - expect(isAsyncFunction(new Int32Array())).toBe(false) - expect(isAsyncFunction(new Uint32Array())).toBe(false) - expect(isAsyncFunction(new Float32Array())).toBe(false) - expect(isAsyncFunction(new Float64Array())).toBe(false) - expect(isAsyncFunction(new BigInt64Array())).toBe(false) - expect(isAsyncFunction(new BigUint64Array())).toBe(false) /* eslint-disable @typescript-eslint/no-empty-function -- Testing with empty functions to verify isAsyncFunction correctly identifies async vs sync */ - expect(isAsyncFunction(new Promise(() => {}))).toBe(false) - expect(isAsyncFunction(new WeakRef({}))).toBe(false) - expect(isAsyncFunction(new FinalizationRegistry(() => {}))).toBe(false) - expect(isAsyncFunction(new ArrayBuffer(16))).toBe(false) - expect(isAsyncFunction(new SharedArrayBuffer(16))).toBe(false) - expect(isAsyncFunction(new DataView(new ArrayBuffer(16)))).toBe(false) - expect(isAsyncFunction({})).toBe(false) - expect(isAsyncFunction({ a: 1 })).toBe(false) - expect(isAsyncFunction(() => {})).toBe(false) - expect(isAsyncFunction(function () {})).toBe(false) - expect(isAsyncFunction(function named () {})).toBe(false) - expect(isAsyncFunction(async () => {})).toBe(true) - expect(isAsyncFunction(async function () {})).toBe(true) - expect(isAsyncFunction(async function named () {})).toBe(true) + const nonAsyncValues: unknown[] = [ + null, + undefined, + true, + false, + 0, + '', + [], + new Date(), + /[a-z]/i, + new Error(), + new Map(), + new Set(), + new WeakMap(), + new WeakSet(), + new Int8Array(), + new Uint8Array(), + new Uint8ClampedArray(), + new Int16Array(), + new Uint16Array(), + new Int32Array(), + new Uint32Array(), + new Float32Array(), + new Float64Array(), + new BigInt64Array(), + new BigUint64Array(), + new Promise(() => {}), + new WeakRef({}), + new FinalizationRegistry(() => {}), + new ArrayBuffer(16), + new SharedArrayBuffer(16), + new DataView(new ArrayBuffer(16)), + {}, + { a: 1 }, + () => {}, + function () {}, + function named () {}, + ] + for (const value of nonAsyncValues) { + expect(isAsyncFunction(value)).toBe(false) + } + + const asyncValues: unknown[] = [async () => {}, async function () {}, async function named () {}] + for (const value of asyncValues) { + expect(isAsyncFunction(value)).toBe(true) + } /* eslint-enable @typescript-eslint/no-empty-function */ + class TestClass { /* eslint-disable @typescript-eslint/no-empty-function -- Testing class methods and properties */ public static async testStaticAsync (): Promise {} @@ -605,4 +623,127 @@ await describe('Utils', async () => { expect(maxDelay).toBeGreaterThanOrEqual(102400) // ~102 seconds expect(maxDelay).toBeLessThanOrEqual(122880) }) + + await it('should return timestamped log prefix with optional string', () => { + const result = logPrefix() + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + const withPrefix = logPrefix(' Test |') + expect(withPrefix).toContain(' Test |') + }) + + await it('should deep merge objects with source overriding target', () => { + // Simple merge + expect(mergeDeepRight({ a: 1 }, { b: 2 })).toStrictEqual({ a: 1, b: 2 }) + // Source overrides target + expect(mergeDeepRight({ a: 1 }, { a: 2 })).toStrictEqual({ a: 2 }) + // Nested merge + expect(mergeDeepRight({ a: { b: 1, c: 2 } }, { a: { c: 3, d: 4 } })).toStrictEqual({ + a: { b: 1, c: 3, d: 4 }, + }) + // Deeply nested + expect(mergeDeepRight({ a: { b: { c: 1 } } }, { a: { b: { d: 2 } } })).toStrictEqual({ + a: { b: { c: 1, d: 2 } }, + }) + // Non-object source value replaces target object + expect(mergeDeepRight({ a: { b: 1 } }, { a: 'string' })).toStrictEqual({ a: 'string' }) + // Empty objects + expect(mergeDeepRight({}, { a: 1 })).toStrictEqual({ a: 1 }) + expect(mergeDeepRight({ a: 1 }, {})).toStrictEqual({ a: 1 }) + }) + + await it('should stringify objects with Map and Set support', () => { + // Basic object + expect(JSONStringify({ a: 1 })).toBe('{"a":1}') + // Map as array (default) + const map = new Map([['key', { value: 1 }]]) + expect(JSONStringify(map)).toBe('[["key",{"value":1}]]') + // Map as object + expect(JSONStringify(map, undefined, MapStringifyFormat.object)).toBe('{"key":{"value":1}}') + // Set + const set = new Set([{ a: 1 }]) + expect(JSONStringify(set)).toBe('[{"a":1}]') + // With space formatting + expect(JSONStringify({ a: 1 }, 2)).toBe('{\n "a": 1\n}') + }) + + await it('should return human readable string for websocket close codes', () => { + // Known codes + expect(getWebSocketCloseEventStatusString(1000)).toBe('Normal Closure') + expect(getWebSocketCloseEventStatusString(1001)).toBe('Going Away') + expect(getWebSocketCloseEventStatusString(1006)).toBe('Abnormal Closure') + expect(getWebSocketCloseEventStatusString(1011)).toBe('Server Internal Error') + // Ranges + expect(getWebSocketCloseEventStatusString(0)).toBe('(Unused)') + expect(getWebSocketCloseEventStatusString(999)).toBe('(Unused)') + expect(getWebSocketCloseEventStatusString(1016)).toBe('(For WebSocket standard)') + expect(getWebSocketCloseEventStatusString(1999)).toBe('(For WebSocket standard)') + expect(getWebSocketCloseEventStatusString(2000)).toBe('(For WebSocket extensions)') + expect(getWebSocketCloseEventStatusString(2999)).toBe('(For WebSocket extensions)') + expect(getWebSocketCloseEventStatusString(3000)).toBe('(For libraries and frameworks)') + expect(getWebSocketCloseEventStatusString(3999)).toBe('(For libraries and frameworks)') + expect(getWebSocketCloseEventStatusString(4000)).toBe('(For applications)') + expect(getWebSocketCloseEventStatusString(4999)).toBe('(For applications)') + // Unknown + expect(getWebSocketCloseEventStatusString(5000)).toBe('(Unknown)') + }) + + await it('should generate random float rounded to specified scale', () => { + const result = getRandomFloatRounded(10, 0, 2) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThanOrEqual(10) + // Check rounding to 2 decimal places + const decimalStr = result.toString() + if (decimalStr.includes('.')) { + expect(decimalStr.split('.')[1].length).toBeLessThanOrEqual(2) + } + // Default scale + const defaultScale = getRandomFloatRounded(10, 0) + expect(defaultScale).toBeGreaterThanOrEqual(0) + expect(defaultScale).toBeLessThanOrEqual(10) + }) + + await it('should generate fluctuated random float within percentage range', () => { + // 0% fluctuation returns static value rounded + expect(getRandomFloatFluctuatedRounded(100, 0)).toBe(100) + // 10% fluctuation: 100 ± 10 + const result = getRandomFloatFluctuatedRounded(100, 10) + expect(result).toBeGreaterThanOrEqual(90) + expect(result).toBeLessThanOrEqual(110) + // Invalid fluctuation percent + expect(() => getRandomFloatFluctuatedRounded(100, -1)).toThrow(RangeError) + expect(() => getRandomFloatFluctuatedRounded(100, 101)).toThrow(RangeError) + // Negative static value with fluctuation + const negResult = getRandomFloatFluctuatedRounded(-100, 10) + expect(negResult).toBeGreaterThanOrEqual(-110) + expect(negResult).toBeLessThanOrEqual(-90) + }) + + await it('should detect Cloud Foundry environment from VCAP_APPLICATION', () => { + const originalVcap = process.env.VCAP_APPLICATION + try { + delete process.env.VCAP_APPLICATION + expect(isCFEnvironment()).toBe(false) + process.env.VCAP_APPLICATION = '{}' + expect(isCFEnvironment()).toBe(true) + } finally { + if (originalVcap != null) { + process.env.VCAP_APPLICATION = originalVcap + } else { + delete process.env.VCAP_APPLICATION + } + } + }) + + await it('should queue microtask that throws the given error', t => { + const error = new Error('test microtask error') + // eslint-disable-next-line @typescript-eslint/no-empty-function -- Mock queueMicrotask with no-op to prevent actual throw + const mockFn = t.mock.method(globalThis, 'queueMicrotask', () => {}) + queueMicrotaskErrorThrowing(error) + expect(mockFn.mock.callCount()).toBe(1) + const callback = mockFn.mock.calls[0].arguments[0] as () => void + expect(() => { + callback() + }).toThrow(error) + }) }) -- 2.43.0