--- /dev/null
+import chalk from 'chalk'
+import Table from 'cli-table3'
+import { type ConnectorEntry, type EvseEntry, OCPP16ChargePointStatus } from 'ui-common'
+
+const NO_BORDER = {
+ bottom: '',
+ 'bottom-left': '',
+ 'bottom-mid': '',
+ 'bottom-right': '',
+ left: '',
+ 'left-mid': '',
+ mid: '',
+ 'mid-mid': '',
+ middle: ' ',
+ right: '',
+ 'right-mid': '',
+ top: '',
+ 'top-left': '',
+ 'top-mid': '',
+ 'top-right': '',
+}
+
+// cspell:ignore borderless
+export const borderlessTable = (head: string[], colWidths?: number[]): Table.Table =>
+ new Table({
+ chars: NO_BORDER,
+ head: head.map(h => chalk.bold(h.toUpperCase())),
+ style: { 'padding-left': 0, 'padding-right': 2 },
+ ...(colWidths != null && { colWidths }),
+ })
+
+export const truncateId = (id: string, len = 12): string =>
+ id.length > len ? `${id.slice(0, len)}…` : id
+
+export const statusIcon = (started: boolean | undefined): string =>
+ started === true ? chalk.green('●') : chalk.dim('○')
+
+export const wsIcon = (wsState: number | undefined): string => {
+ switch (wsState) {
+ case 0:
+ return chalk.yellow('…')
+ case 1:
+ return chalk.green('✓')
+ case 2:
+ case 3:
+ return chalk.red('✗')
+ default:
+ return chalk.dim('–')
+ }
+}
+
+export const countConnectors = (
+ evses: EvseEntry[],
+ connectors: ConnectorEntry[]
+): { available: number; total: number } => {
+ let total = 0
+ let available = 0
+
+ if (evses.length > 0) {
+ for (const evse of evses) {
+ if (evse.evseId > 0) {
+ for (const c of evse.evseStatus.connectors) {
+ if (c.connectorId > 0) {
+ total++
+ if (c.connectorStatus.status === OCPP16ChargePointStatus.AVAILABLE) {
+ available++
+ }
+ }
+ }
+ }
+ }
+ } else {
+ for (const c of connectors) {
+ if (c.connectorId > 0) {
+ total++
+ if (c.connectorStatus.status === OCPP16ChargePointStatus.AVAILABLE) {
+ available++
+ }
+ }
+ }
+ }
+
+ return { available, total }
+}
+
+export const fuzzyTime = (ts: number | undefined): string => {
+ if (ts == null) return chalk.dim('–')
+ const diff = Math.max(0, Date.now() - ts)
+ const seconds = Math.floor(diff / 1000)
+ if (seconds < 60) return chalk.dim('just now')
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return chalk.dim(`${minutes.toString()}m ago`)
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return chalk.dim(`${hours.toString()}h ago`)
+ const days = Math.floor(hours / 24)
+ return chalk.dim(`${days.toString()}d ago`)
+}
--- /dev/null
+import type { ConnectorEntry, EvseEntry, ResponsePayload } from 'ui-common'
+
+import chalk from 'chalk'
+import process from 'node:process'
+
+import {
+ borderlessTable,
+ countConnectors,
+ fuzzyTime,
+ statusIcon,
+ truncateId,
+ wsIcon,
+} from './format.js'
+
+type PerformancePayload = ResponsePayload & {
+ performanceStatistics: unknown[]
+}
+
+type SimulatorStatePayload = ResponsePayload & {
+ state: {
+ configuration?: {
+ supervisionUrls?: string | string[]
+ worker?: { elementsPerWorker?: string; processType?: string }
+ }
+ started: boolean
+ templateStatistics: Record<
+ string,
+ {
+ added: number
+ configured: number
+ indexes: number[]
+ provisioned: number
+ started: number
+ }
+ >
+ version: string
+ }
+}
+
+type StationPayload = ResponsePayload & {
+ chargingStations: {
+ connectors?: ConnectorEntry[]
+ evses?: EvseEntry[]
+ started?: boolean
+ stationInfo: {
+ chargingStationId: string
+ hashId: string
+ ocppVersion?: string
+ templateName?: string
+ }
+ supervisionUrl?: string
+ timestamp?: number
+ wsState?: number
+ }[]
+}
+
+type TemplatePayload = ResponsePayload & {
+ templates: string[]
+}
+
+const isPerformanceStats = (p: ResponsePayload): p is PerformancePayload =>
+ 'performanceStatistics' in p && Array.isArray(p.performanceStatistics)
+
+const isSimulatorState = (p: ResponsePayload): p is SimulatorStatePayload => {
+ if (!('state' in p) || p.state == null || typeof p.state !== 'object') return false
+ const state = p.state as Record<string, unknown>
+ return (
+ 'version' in state &&
+ 'templateStatistics' in state &&
+ typeof state.templateStatistics === 'object' &&
+ state.templateStatistics != null
+ )
+}
+
+const isStationList = (p: ResponsePayload): p is StationPayload =>
+ 'chargingStations' in p && Array.isArray(p.chargingStations)
+
+const isTemplateList = (p: ResponsePayload): p is TemplatePayload =>
+ 'templates' in p && Array.isArray(p.templates)
+
+const renderPerformanceStats = (payload: PerformancePayload): void => {
+ const stats = payload.performanceStatistics
+ if (stats.length === 0) {
+ process.stdout.write(chalk.dim('No performance statistics collected\n'))
+ return
+ }
+ process.stdout.write(JSON.stringify(stats, null, 2) + '\n')
+}
+
+const renderSimulatorState = (payload: SimulatorStatePayload): void => {
+ const { state } = payload
+ const stats = state.templateStatistics
+
+ process.stdout.write(chalk.bold('Simulator\n'))
+ process.stdout.write(
+ ` Status ${statusIcon(state.started)} ${state.started ? 'started' : 'stopped'}\n`
+ )
+ 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`)
+ }
+ if (
+ state.configuration?.supervisionUrls != null &&
+ state.configuration.supervisionUrls.length > 0
+ ) {
+ const urls = Array.isArray(state.configuration.supervisionUrls)
+ ? state.configuration.supervisionUrls
+ : [state.configuration.supervisionUrls]
+ process.stdout.write(` CSMS ${chalk.dim(urls.join(', '))}\n`)
+ }
+
+ const activeTemplates = Object.entries(stats).filter(([, s]) => s.added > 0 || s.provisioned > 0)
+ if (activeTemplates.length > 0) {
+ process.stdout.write(chalk.bold('\nTemplates\n'))
+ const table = borderlessTable(['Name', 'Added', 'Started', 'Provisioned', 'Configured'])
+ for (const [name, s] of activeTemplates) {
+ table.push([
+ name.replace('.station-template', ''),
+ 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'),
+ s.configured > 0 ? s.configured.toString() : chalk.dim('0'),
+ ])
+ }
+ process.stdout.write(`${table.toString()}\n`)
+ }
+
+ const totalAdded = Object.values(stats).reduce((sum, s) => sum + s.added, 0)
+ const totalStarted = Object.values(stats).reduce((sum, s) => sum + s.started, 0)
+ process.stdout.write(
+ chalk.dim(
+ `\n${totalAdded.toString()} station${totalAdded !== 1 ? 's' : ''} added, ${totalStarted.toString()} started\n`
+ )
+ )
+}
+
+const renderStationList = (payload: StationPayload): void => {
+ const stations = payload.chargingStations
+ if (stations.length === 0) {
+ process.stdout.write(chalk.dim('No charging stations\n'))
+ return
+ }
+
+ const table = borderlessTable(['', 'Name', 'Hash ID', 'WS', 'OCPP', 'Template', 'Updated'])
+ for (const cs of stations) {
+ const si = cs.stationInfo
+ const { available, total } = countConnectors(cs.evses ?? [], cs.connectors ?? [])
+ table.push([
+ statusIcon(cs.started),
+ si.chargingStationId,
+ chalk.dim(truncateId(si.hashId)),
+ `${wsIcon(cs.wsState)} ${chalk.dim(`${available.toString()}/${total.toString()}`)}`,
+ chalk.dim(si.ocppVersion ?? '–'),
+ chalk.dim(si.templateName?.replace('.station-template', '') ?? '–'),
+ fuzzyTime(cs.timestamp),
+ ])
+ }
+ process.stdout.write(`${table.toString()}\n`)
+
+ const started = stations.filter(s => s.started === true).length
+ const connected = stations.filter(s => s.wsState === 1).length
+ process.stdout.write(
+ chalk.dim(
+ `\n${stations.length.toString()} station${stations.length !== 1 ? 's' : ''} (${started.toString()} started, ${connected.toString()} connected)\n`
+ )
+ )
+}
+
+const renderTemplateList = (payload: TemplatePayload): void => {
+ const templates = payload.templates
+ if (templates.length === 0) {
+ process.stdout.write(chalk.dim('No templates available\n'))
+ return
+ }
+ for (const t of templates) {
+ process.stdout.write(`${t}\n`)
+ }
+ process.stdout.write(
+ chalk.dim(`\n${templates.length.toString()} template${templates.length !== 1 ? 's' : ''}\n`)
+ )
+}
+
+export const tryRenderPayload = (payload: ResponsePayload): boolean => {
+ if (isStationList(payload)) {
+ renderStationList(payload)
+ return true
+ }
+ if (isTemplateList(payload)) {
+ renderTemplateList(payload)
+ return true
+ }
+ if (isSimulatorState(payload)) {
+ renderSimulatorState(payload)
+ return true
+ }
+ if (isPerformanceStats(payload)) {
+ renderPerformanceStats(payload)
+ return true
+ }
+ return false
+}
import chalk from 'chalk'
-import Table from 'cli-table3'
import process from 'node:process'
import { type ResponsePayload, ResponseStatus } from 'ui-common'
-const hashIdTable = (ids: string[]) => {
- const table = new Table({ head: [chalk.white('Hash ID')] })
- for (const id of ids) {
- table.push([id])
- }
- return table
-}
+import { borderlessTable } from './format.js'
+import { tryRenderPayload } from './renderers.js'
export const outputTable = (payload: ResponsePayload): void => {
+ if (tryRenderPayload(payload)) return
+
if (payload.hashIdsSucceeded != null && payload.hashIdsSucceeded.length > 0) {
process.stdout.write(
chalk.green(`✓ Succeeded (${payload.hashIdsSucceeded.length.toString()}):\n`)
)
- const table = hashIdTable(payload.hashIdsSucceeded)
+ const table = borderlessTable(['Hash ID'])
+ for (const id of payload.hashIdsSucceeded) {
+ table.push([id])
+ }
process.stdout.write(table.toString() + '\n')
}
if (payload.hashIdsFailed != null && payload.hashIdsFailed.length > 0) {
process.stderr.write(chalk.red(`✗ Failed (${payload.hashIdsFailed.length.toString()}):\n`))
if (payload.responsesFailed != null && payload.responsesFailed.length > 0) {
- const table = new Table({ head: [chalk.white('Hash ID'), chalk.white('Error')] })
+ const table = borderlessTable(['Hash ID', 'Error'])
for (const entry of payload.responsesFailed) {
table.push([entry.hashId ?? '(unknown)', entry.errorMessage ?? 'Unknown error'])
}
process.stderr.write(table.toString() + '\n')
} else {
- const table = hashIdTable(payload.hashIdsFailed)
+ const table = borderlessTable(['Hash ID'])
+ for (const id of payload.hashIdsFailed) {
+ table.push([id])
+ }
process.stderr.write(table.toString() + '\n')
}
}
} else if (status === ResponseStatus.SUCCESS) {
process.stdout.write(chalk.green('✓ Success\n'))
} else {
- process.stderr.write(chalk.red(`✗ ${status}\n`))
+ const label =
+ typeof status === 'string' && status.length > 0
+ ? status.charAt(0).toUpperCase() + status.slice(1)
+ : 'Failure'
+ process.stderr.write(chalk.red(`✗ ${label}\n`))
}
}
--- /dev/null
+import chalk from 'chalk'
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+import { OCPP16AvailabilityType, OCPP16ChargePointStatus } from 'ui-common'
+
+import { countConnectors, fuzzyTime, statusIcon, truncateId, wsIcon } from '../src/output/format.js'
+
+await describe('format helpers', async () => {
+ await describe('truncateId', async () => {
+ await it('truncates string longer than default len (12) and appends ellipsis', () => {
+ const result = truncateId('abcdefghijklmnop')
+ assert.strictEqual(result, 'abcdefghijkl…')
+ })
+
+ await it('returns string as-is when shorter than default len', () => {
+ const result = truncateId('short')
+ assert.strictEqual(result, 'short')
+ })
+
+ await it('returns string as-is when exactly default len', () => {
+ const result = truncateId('abcdefghijkl')
+ assert.strictEqual(result, 'abcdefghijkl')
+ })
+
+ await it('works with custom len parameter', () => {
+ const result = truncateId('hello world', 5)
+ assert.strictEqual(result, 'hello…')
+ })
+
+ await it('returns string as-is when shorter than custom len', () => {
+ const result = truncateId('hi', 5)
+ assert.strictEqual(result, 'hi')
+ })
+ })
+
+ await describe('statusIcon', async () => {
+ await it('returns green icon for true', () => {
+ const result = statusIcon(true)
+ assert.strictEqual(result, chalk.green('●'))
+ assert.ok(result.includes('●'))
+ })
+
+ await it('returns dim icon for false', () => {
+ const result = statusIcon(false)
+ assert.strictEqual(result, chalk.dim('○'))
+ assert.ok(result.includes('○'))
+ })
+
+ await it('returns dim icon for undefined', () => {
+ const result = statusIcon(undefined)
+ assert.strictEqual(result, chalk.dim('○'))
+ assert.ok(result.includes('○'))
+ })
+ })
+
+ await describe('wsIcon', async () => {
+ await it('returns yellow icon for state 0 (CONNECTING)', () => {
+ const result = wsIcon(0)
+ assert.strictEqual(result, chalk.yellow('…'))
+ assert.ok(result.includes('…'))
+ })
+
+ await it('returns green icon for state 1 (OPEN)', () => {
+ const result = wsIcon(1)
+ assert.strictEqual(result, chalk.green('✓'))
+ assert.ok(result.includes('✓'))
+ })
+
+ await it('returns red icon for state 2 (CLOSING)', () => {
+ const result = wsIcon(2)
+ assert.strictEqual(result, chalk.red('✗'))
+ assert.ok(result.includes('✗'))
+ })
+
+ await it('returns red icon for state 3 (CLOSED)', () => {
+ const result = wsIcon(3)
+ assert.strictEqual(result, chalk.red('✗'))
+ assert.ok(result.includes('✗'))
+ })
+
+ await it('returns dim icon for undefined', () => {
+ const result = wsIcon(undefined)
+ assert.strictEqual(result, chalk.dim('–'))
+ assert.ok(result.includes('–'))
+ })
+
+ await it('returns dim icon for unknown state', () => {
+ const result = wsIcon(99)
+ assert.strictEqual(result, chalk.dim('–'))
+ })
+ })
+
+ await describe('countConnectors', async () => {
+ await it('counts from evses when evses present (evseId > 0, connectorId > 0)', () => {
+ const evses = [
+ {
+ evseId: 1,
+ evseStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ {
+ connectorId: 2,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.CHARGING,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ const result = countConnectors(evses, [])
+ assert.deepStrictEqual(result, { available: 1, total: 2 })
+ })
+
+ await it('skips evseId 0', () => {
+ const evses = [
+ {
+ evseId: 0,
+ evseStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ ],
+ },
+ },
+ {
+ evseId: 1,
+ evseStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ const result = countConnectors(evses, [])
+ assert.deepStrictEqual(result, { available: 1, total: 1 })
+ })
+
+ await it('skips connectorId 0 within evses', () => {
+ const evses = [
+ {
+ evseId: 1,
+ evseStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ connectors: [
+ {
+ connectorId: 0,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ const result = countConnectors(evses, [])
+ assert.deepStrictEqual(result, { available: 1, total: 1 })
+ })
+
+ await it('counts available connectors (status === AVAILABLE)', () => {
+ const evses = [
+ {
+ evseId: 1,
+ evseStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ {
+ connectorId: 2,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.UNAVAILABLE,
+ },
+ },
+ {
+ connectorId: 3,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.FAULTED,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ const result = countConnectors(evses, [])
+ assert.deepStrictEqual(result, { available: 1, total: 3 })
+ })
+
+ await it('falls back to connectors array when evses empty', () => {
+ const connectors = [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ {
+ connectorId: 2,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.CHARGING,
+ },
+ },
+ ]
+ const result = countConnectors([], connectors)
+ assert.deepStrictEqual(result, { available: 1, total: 2 })
+ })
+
+ await it('skips connectorId 0 in connectors fallback', () => {
+ const connectors = [
+ {
+ connectorId: 0,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ ]
+ const result = countConnectors([], connectors)
+ assert.deepStrictEqual(result, { available: 1, total: 1 })
+ })
+
+ await it('returns {available: 0, total: 0} for empty arrays', () => {
+ const result = countConnectors([], [])
+ assert.deepStrictEqual(result, { available: 0, total: 0 })
+ })
+ })
+
+ await describe('fuzzyTime', async () => {
+ await it('returns dim dash for undefined', () => {
+ const result = fuzzyTime(undefined)
+ assert.strictEqual(result, chalk.dim('–'))
+ })
+
+ await it('returns just now for timestamps within last 60 seconds', () => {
+ const thirtySecondsAgo = Date.now() - 30_000
+ const result = fuzzyTime(thirtySecondsAgo)
+ assert.strictEqual(result, chalk.dim('just now'))
+ })
+
+ await it('returns just now for ts = Date.now()', () => {
+ const result = fuzzyTime(Date.now())
+ assert.strictEqual(result, chalk.dim('just now'))
+ })
+
+ await it('clamps future timestamps to just now', () => {
+ const oneMinuteInFuture = Date.now() + 60_000
+ const result = fuzzyTime(oneMinuteInFuture)
+ assert.strictEqual(result, chalk.dim('just now'))
+ })
+
+ await it('returns minutes format for timestamps 1-59 minutes ago', () => {
+ const fiveMinutesAgo = Date.now() - 5 * 60_000
+ const result = fuzzyTime(fiveMinutesAgo)
+ assert.strictEqual(result, chalk.dim('5m ago'))
+ })
+
+ await it('returns minutes format for exactly 1 minute ago', () => {
+ const sixtySecondsAgo = Date.now() - 60_000
+ const result = fuzzyTime(sixtySecondsAgo)
+ assert.strictEqual(result, chalk.dim('1m ago'))
+ })
+
+ await it('returns hours format for timestamps 1-23 hours ago', () => {
+ const threeHoursAgo = Date.now() - 3 * 60 * 60_000
+ const result = fuzzyTime(threeHoursAgo)
+ assert.strictEqual(result, chalk.dim('3h ago'))
+ })
+
+ await it('returns hours format for exactly 1 hour ago', () => {
+ const sixtyMinutesAgo = Date.now() - 60 * 60_000
+ const result = fuzzyTime(sixtyMinutesAgo)
+ assert.strictEqual(result, chalk.dim('1h ago'))
+ })
+
+ await it('returns days format for timestamps 24+ hours ago', () => {
+ const twoDaysAgo = Date.now() - 2 * 24 * 60 * 60_000
+ const result = fuzzyTime(twoDaysAgo)
+ assert.strictEqual(result, chalk.dim('2d ago'))
+ })
+
+ await it('returns days format for exactly 24 hours ago', () => {
+ const twentyFourHoursAgo = Date.now() - 24 * 60 * 60_000
+ const result = fuzzyTime(twentyFourHoursAgo)
+ assert.strictEqual(result, chalk.dim('1d ago'))
+ })
+ })
+})
--- /dev/null
+import process from 'node:process'
+
+export const captureStream = (stream: 'stderr' | 'stdout', fn: () => void): string => {
+ const target = stream === 'stdout' ? process.stdout : process.stderr
+ const chunks: string[] = []
+ const original = target.write.bind(target)
+ target.write = ((chunk: string): boolean => {
+ chunks.push(chunk)
+ return true
+ }) as typeof target.write
+ try {
+ fn()
+ } finally {
+ target.write = original
+ }
+ return chunks.join('')
+}
import { printError } from '../src/output/human.js'
import { outputJson, outputJsonError } from '../src/output/json.js'
import { outputTable } from '../src/output/table.js'
-
-const captureStream = (stream: 'stderr' | 'stdout', fn: () => void): string => {
- const target = stream === 'stdout' ? process.stdout : process.stderr
- const chunks: string[] = []
- const original = target.write.bind(target)
- target.write = ((chunk: string): boolean => {
- chunks.push(chunk)
- return true
- }) as typeof target.write
- try {
- fn()
- } finally {
- target.write = original
- }
- return chunks.join('')
-}
+import { captureStream } from './helpers.js'
await describe('output formatters', async () => {
await it('should create JSON formatter when jsonMode is true', () => {
})
assert.ok(output.includes('Station not found'))
assert.ok(output.includes('cs-001'))
- assert.ok(output.includes('Error'))
+ assert.ok(output.includes('ERROR'))
})
await it('should display responsesFailed with missing errorMessage as Unknown error', () => {
const output = captureStream('stderr', () => {
outputTable({ status: ResponseStatus.FAILURE })
})
- assert.ok(output.includes('failure'))
+ assert.ok(output.includes('Failure'))
})
})
--- /dev/null
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+import { OCPP16AvailabilityType, OCPP16ChargePointStatus, ResponseStatus } from 'ui-common'
+
+import { tryRenderPayload } from '../src/output/renderers.js'
+import { captureStream } from './helpers.js'
+
+const stationListPayload = {
+ chargingStations: [
+ {
+ connectors: [
+ {
+ connectorId: 1,
+ connectorStatus: {
+ availability: OCPP16AvailabilityType.OPERATIVE,
+ status: OCPP16ChargePointStatus.AVAILABLE,
+ },
+ },
+ ],
+ evses: [],
+ started: true,
+ stationInfo: {
+ chargingStationId: 'CS-001',
+ hashId: 'abcdef123456789012345678',
+ ocppVersion: '2.0.1',
+ templateName: 'test.station-template',
+ },
+ wsState: 1,
+ },
+ ],
+ status: ResponseStatus.SUCCESS,
+}
+
+const simulatorStatePayload = {
+ state: {
+ configuration: {
+ supervisionUrls: ['ws://localhost:8180'],
+ worker: { elementsPerWorker: 'all', processType: 'workerSet' },
+ },
+ started: true,
+ templateStatistics: {
+ 'test.station-template': {
+ added: 2,
+ configured: 1,
+ indexes: [0, 1],
+ provisioned: 0,
+ started: 2,
+ },
+ },
+ version: '4.0.0',
+ },
+ status: ResponseStatus.SUCCESS,
+}
+
+await describe('renderers', async () => {
+ await describe('tryRenderPayload dispatch', async () => {
+ await it('returns false for payload without known keys', () => {
+ const result = tryRenderPayload({ status: ResponseStatus.SUCCESS })
+ assert.strictEqual(result, false)
+ })
+
+ await it('returns true for station list payload', () => {
+ let result = false
+ captureStream('stdout', () => {
+ result = tryRenderPayload(stationListPayload)
+ })
+ assert.strictEqual(result, true)
+ })
+
+ await it('returns true for template list payload', () => {
+ let result = false
+ captureStream('stdout', () => {
+ result = tryRenderPayload({
+ status: ResponseStatus.SUCCESS,
+ templates: ['template-a', 'template-b'],
+ })
+ })
+ assert.strictEqual(result, true)
+ })
+
+ await it('returns true for simulator state payload', () => {
+ let result = false
+ captureStream('stdout', () => {
+ result = tryRenderPayload(simulatorStatePayload)
+ })
+ assert.strictEqual(result, true)
+ })
+
+ await it('returns true for performance stats payload', () => {
+ let result = false
+ captureStream('stdout', () => {
+ result = tryRenderPayload({ performanceStatistics: [], status: ResponseStatus.SUCCESS })
+ })
+ assert.strictEqual(result, true)
+ })
+ })
+
+ await describe('type guard specifics', async () => {
+ await it('isStationList rejects non-array chargingStations', () => {
+ const result = tryRenderPayload({
+ chargingStations: 'not-array',
+ status: ResponseStatus.SUCCESS,
+ })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isTemplateList rejects non-array templates', () => {
+ const result = tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: 'not-array' })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isPerformanceStats rejects non-array performanceStatistics', () => {
+ const result = tryRenderPayload({
+ performanceStatistics: 'not-array',
+ status: ResponseStatus.SUCCESS,
+ })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isSimulatorState rejects state without version', () => {
+ const result = tryRenderPayload({
+ state: {
+ configuration: {},
+ started: true,
+ templateStatistics: {},
+ },
+ status: ResponseStatus.SUCCESS,
+ })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isSimulatorState rejects state without templateStatistics', () => {
+ const result = tryRenderPayload({
+ state: {
+ configuration: {},
+ started: true,
+ version: '1.0.0',
+ },
+ status: ResponseStatus.SUCCESS,
+ })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isSimulatorState accepts state without configuration', () => {
+ const output = captureStream('stdout', () => {
+ const result = tryRenderPayload({
+ state: {
+ started: true,
+ templateStatistics: {},
+ version: '1.0.0',
+ },
+ status: ResponseStatus.SUCCESS,
+ })
+ assert.strictEqual(result, true)
+ })
+ assert.ok(output.includes('1.0.0'))
+ })
+
+ await it('isSimulatorState rejects null state', () => {
+ const result = tryRenderPayload({ state: null, status: ResponseStatus.SUCCESS })
+ assert.strictEqual(result, false)
+ })
+
+ await it('isSimulatorState rejects non-object state (string)', () => {
+ const result = tryRenderPayload({ state: 'not-an-object', status: ResponseStatus.SUCCESS })
+ assert.strictEqual(result, false)
+ })
+ })
+
+ await describe('renderStationList', async () => {
+ await it('renders empty message for empty array', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({ chargingStations: [], status: ResponseStatus.SUCCESS })
+ })
+ assert.ok(output.includes('No charging stations'))
+ })
+
+ await it('renders table with station name', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(stationListPayload)
+ })
+ assert.ok(output.includes('CS-001'))
+ })
+
+ await it('renders table with truncated hash id', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(stationListPayload)
+ })
+ assert.ok(output.includes('abcdef123456'))
+ })
+
+ await it('renders footer with station count', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(stationListPayload)
+ })
+ assert.ok(output.includes('1 station'))
+ })
+
+ await it('renders footer with started and connected counts', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(stationListPayload)
+ })
+ assert.ok(output.includes('started'))
+ assert.ok(output.includes('connected'))
+ })
+ })
+
+ await describe('renderTemplateList', async () => {
+ await it('renders one template per line', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({
+ status: ResponseStatus.SUCCESS,
+ templates: ['template-a', 'template-b'],
+ })
+ })
+ assert.ok(output.includes('template-a'))
+ assert.ok(output.includes('template-b'))
+ })
+
+ await it('renders footer count', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({
+ status: ResponseStatus.SUCCESS,
+ templates: ['template-a', 'template-b'],
+ })
+ })
+ assert.ok(output.includes('2 templates'))
+ })
+
+ await it('renders singular footer for single template', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: ['only-one'] })
+ })
+ assert.ok(output.includes('1 template'))
+ assert.ok(!output.includes('1 templates'))
+ })
+
+ await it('renders empty message for empty array', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({ status: ResponseStatus.SUCCESS, templates: [] })
+ })
+ assert.ok(output.includes('No templates available'))
+ })
+ })
+
+ await describe('renderSimulatorState', async () => {
+ await it('renders version', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('4.0.0'))
+ })
+
+ await it('renders started status', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('started'))
+ })
+
+ await it('renders template statistics table', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('test'))
+ })
+
+ await it('renders footer with station counts', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('2 stations'))
+ })
+
+ await it('renders worker info when present', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('workerSet'))
+ })
+
+ await it('renders supervision URL when present', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(simulatorStatePayload)
+ })
+ assert.ok(output.includes('ws://localhost:8180'))
+ })
+
+ await it('renders stopped status when started is false', () => {
+ const stoppedPayload = {
+ state: {
+ ...simulatorStatePayload.state,
+ started: false,
+ },
+ status: ResponseStatus.SUCCESS,
+ }
+ const output = captureStream('stdout', () => {
+ tryRenderPayload(stoppedPayload)
+ })
+ assert.ok(output.includes('stopped'))
+ })
+ })
+
+ await describe('renderPerformanceStats', async () => {
+ await it('renders empty message for empty array', () => {
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({ performanceStatistics: [], status: ResponseStatus.SUCCESS })
+ })
+ assert.ok(output.includes('No performance statistics collected'))
+ })
+
+ await it('renders JSON for non-empty stats array', () => {
+ const stats = [{ id: 'cs-001', measurements: {} }]
+ const output = captureStream('stdout', () => {
+ tryRenderPayload({ performanceStatistics: stats, status: ResponseStatus.SUCCESS })
+ })
+ assert.ok(output.includes('cs-001'))
+ })
+ })
+})
started: boolean
stationInfo: ChargingStationInfo
supervisionUrl: string
+ timestamp?: number
wsState?: 0 | 1 | 2 | 3
}
}
export interface SimulatorState {
+ configuration?: {
+ supervisionUrls?: string | string[]
+ worker?: {
+ elementsPerWorker?: string
+ processType?: string
+ }
+ }
started: boolean
templateStatistics: Record<string, TemplateStatistics>
version: string
added: number
configured: number
indexes: number[]
+ provisioned: number
started: number
}