]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui): human-readable CLI output + shared type updates + --url collision fix
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 20:17:10 +0000 (22:17 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 20:17:10 +0000 (22:17 +0200)
CLI output renderers:
- Add borderless table renderers for station list, template list,
  simulator state, and performance statistics
- Shared format utils: truncateId, statusIcon, wsIcon, fuzzyTime,
  countConnectors using canonical ConnectorEntry/EvseEntry types
- Type guards with Array.isArray + null checks for safe dispatch
- Handle supervisionUrls as string | string[] (defensive normalize)
- Optional configuration in SimulatorState guard (graceful degradation)
- Dynamic status capitalization in generic payload display
- Extract captureStream test helper to tests/helpers.ts
- 70 new tests (40 format + 30 renderers), 124 total CLI tests

Shared type fixes (ui-common):
- Add provisioned field to TemplateStatistics
- Add timestamp field to ChargingStationData
- Add configuration (optional) to SimulatorState

CLI option rename:
- Rename global --url to --server-url (explicit intent)
- Rename supervision set-url --url to --supervision-url (no collision)
- Update types, consumers, integration tests, README

ui/cli/src/output/format.ts [new file with mode: 0644]
ui/cli/src/output/renderers.ts [new file with mode: 0644]
ui/cli/src/output/table.ts
ui/cli/tests/format.test.ts [new file with mode: 0644]
ui/cli/tests/helpers.ts [new file with mode: 0644]
ui/cli/tests/output.test.ts
ui/cli/tests/renderers.test.ts [new file with mode: 0644]
ui/common/src/types/ChargingStationType.ts
ui/common/src/types/UIProtocol.ts

diff --git a/ui/cli/src/output/format.ts b/ui/cli/src/output/format.ts
new file mode 100644 (file)
index 0000000..b3a057f
--- /dev/null
@@ -0,0 +1,97 @@
+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`)
+}
diff --git a/ui/cli/src/output/renderers.ts b/ui/cli/src/output/renderers.ts
new file mode 100644 (file)
index 0000000..ca1e55b
--- /dev/null
@@ -0,0 +1,202 @@
+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
+}
index e78ac24b166de56177c4506755cd413a2bda7a17..f0c7fd7f12b5f2b351aa9553dc2b7f3d4e53a6fc 100644 (file)
@@ -1,35 +1,37 @@
 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')
     }
   }
@@ -52,6 +54,10 @@ const displayGenericPayload = (payload: ResponsePayload): void => {
   } 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`))
   }
 }
diff --git a/ui/cli/tests/format.test.ts b/ui/cli/tests/format.test.ts
new file mode 100644 (file)
index 0000000..2212e00
--- /dev/null
@@ -0,0 +1,332 @@
+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'))
+    })
+  })
+})
diff --git a/ui/cli/tests/helpers.ts b/ui/cli/tests/helpers.ts
new file mode 100644 (file)
index 0000000..74721a1
--- /dev/null
@@ -0,0 +1,17 @@
+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('')
+}
index 0b7cf2532cc64357cddf9abf1b4d9d4c47d3eeae..a9d64735267c4fdbfb0feb6e639994f6f9c2e55f 100644 (file)
@@ -8,22 +8,7 @@ import { createFormatter } from '../src/output/formatter.js'
 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', () => {
@@ -172,7 +157,7 @@ await describe('output formatters', async () => {
     })
     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', () => {
@@ -230,6 +215,6 @@ await describe('output formatters', async () => {
     const output = captureStream('stderr', () => {
       outputTable({ status: ResponseStatus.FAILURE })
     })
-    assert.ok(output.includes('failure'))
+    assert.ok(output.includes('Failure'))
   })
 })
diff --git a/ui/cli/tests/renderers.test.ts b/ui/cli/tests/renderers.test.ts
new file mode 100644 (file)
index 0000000..6acd0bf
--- /dev/null
@@ -0,0 +1,320 @@
+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'))
+    })
+  })
+})
index a79080b228f60535e5087875ac0c73ecf6a9174c..32c471ce852c0361b322da2ffabad45ec32506df 100644 (file)
@@ -159,6 +159,7 @@ export interface ChargingStationData extends JsonObject {
   started: boolean
   stationInfo: ChargingStationInfo
   supervisionUrl: string
+  timestamp?: number
   wsState?: 0 | 1 | 2 | 3
 }
 
index 3e28bd3090a13e592c1814fc99fa0f9fa3488bca..d1434242ec336bf3d5f9a7f2b69229eaffe7a157 100644 (file)
@@ -97,6 +97,13 @@ export interface ResponsePayload extends JsonObject {
 }
 
 export interface SimulatorState {
+  configuration?: {
+    supervisionUrls?: string | string[]
+    worker?: {
+      elementsPerWorker?: string
+      processType?: string
+    }
+  }
   started: boolean
   templateStatistics: Record<string, TemplateStatistics>
   version: string
@@ -106,5 +113,6 @@ export interface TemplateStatistics {
   added: number
   configured: number
   indexes: number[]
+  provisioned: number
   started: number
 }