]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
test(webui): add comprehensive unit test suite (#1738)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 19 Mar 2026 11:39:02 +0000 (12:39 +0100)
committerGitHub <noreply@github.com>
Thu, 19 Mar 2026 11:39:02 +0000 (12:39 +0100)
* test(webui): add test infrastructure and setup

* test(webui): add Utils composable tests

* test(webui): refactor and expand UIClient tests

* test(webui): add action modal component tests

* test(webui): fix JSDoc warnings in mount factory functions

* test(webui): add ChargingStationsView tests

* [autofix.ci] apply automated fixes

* test(webui): finalize coverage and verify quality gates

* test(webui): harmonize test infrastructure — extract ButtonStub, unify toast mock, DRY error tests

* test(webui): add missing coverage for timeout, server switching, authorize errors, WS states

* test(webui): raise coverage thresholds to match achieved 93%/91%/85%/93%

* test(webui): address PR review — unify mock cleanup, improve MockWebSocket fidelity, fix types

* test(webui): adapt tests for new OCPP Version column

* test(webui): remove MockWebSocket auto-open, robustify component lookups, add open assertion

* test(webui): init MockWebSocket readyState to CONNECTING per WebSocket spec

* fix(webui): use window.localStorage for Node 22+ jsdom compatibility

* fix(webui): disable Node 25+ native webstorage to prevent jsdom localStorage conflict

* test(webui): await router.isReady() in App mount test

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
15 files changed:
ui/web/tests/setup.ts [new file with mode: 0644]
ui/web/tests/unit/AddChargingStations.test.ts [new file with mode: 0644]
ui/web/tests/unit/CSConnector.test.ts [new file with mode: 0644]
ui/web/tests/unit/CSData.test.ts [new file with mode: 0644]
ui/web/tests/unit/CSTable.test.ts
ui/web/tests/unit/ChargingStationsView.test.ts [new file with mode: 0644]
ui/web/tests/unit/SetSupervisionUrl.test.ts [new file with mode: 0644]
ui/web/tests/unit/SimpleComponents.test.ts [new file with mode: 0644]
ui/web/tests/unit/StartTransaction.test.ts [new file with mode: 0644]
ui/web/tests/unit/ToggleButton.test.ts [new file with mode: 0644]
ui/web/tests/unit/UIClient.test.ts
ui/web/tests/unit/Utils.test.ts [new file with mode: 0644]
ui/web/tests/unit/constants.ts [new file with mode: 0644]
ui/web/tests/unit/helpers.ts [new file with mode: 0644]
ui/web/vitest.config.ts

diff --git a/ui/web/tests/setup.ts b/ui/web/tests/setup.ts
new file mode 100644 (file)
index 0000000..b1a7c71
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * @file Global test setup for Vue.js web UI unit tests
+ * @description Shared mocks, stubs, and cleanup for all test files.
+ *   Conventions follow the TEST_STYLE_GUIDE.md naming patterns adapted
+ *   to the Vitest + `@vue/test-utils` assertion API.
+ */
+import { config } from '@vue/test-utils'
+import { afterEach, type Mock, vi } from 'vitest'
+
+// Global stubs: stub router components in all tests by default
+config.global.stubs = {
+  RouterLink: true,
+  RouterView: true,
+}
+
+// ── Shared toast mock ─────────────────────────────────────────────────────────
+// Shared across all test files. Import `toastMock` from setup.ts to assert on calls.
+export const toastMock: { error: Mock; info: Mock; success: Mock; warning: Mock } = {
+  error: vi.fn(),
+  info: vi.fn(),
+  success: vi.fn(),
+  warning: vi.fn(),
+}
+
+vi.mock('vue-toast-notification', () => ({
+  useToast: () => toastMock,
+}))
+
+afterEach(() => {
+  localStorage.clear()
+})
diff --git a/ui/web/tests/unit/AddChargingStations.test.ts b/ui/web/tests/unit/AddChargingStations.test.ts
new file mode 100644 (file)
index 0000000..4a7d0d2
--- /dev/null
@@ -0,0 +1,114 @@
+/**
+ * @file Tests for AddChargingStations component
+ * @description Unit tests for add stations form — template selection, submission, and navigation.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import AddChargingStations from '@/components/actions/AddChargingStations.vue'
+
+import { toastMock } from '../setup'
+import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+
+describe('AddChargingStations', () => {
+  let mockClient: MockUIClient
+  let mockRouter: { push: ReturnType<typeof vi.fn> }
+
+  /**
+   * Mounts AddChargingStations with mock UIClient, router, and templates.
+   * @returns Mounted component wrapper
+   */
+  function mountComponent () {
+    mockClient = createMockUIClient()
+    mockRouter = { push: vi.fn() }
+    return mount(AddChargingStations, {
+      global: {
+        config: {
+          globalProperties: {
+            $router: mockRouter,
+            $templates: ref(['template-A.json', 'template-B.json']),
+            $toast: toastMock,
+            $uiClient: mockClient,
+          } as never,
+        },
+        stubs: {
+          Button: ButtonStub,
+        },
+      },
+    })
+  }
+
+  it('should render template select dropdown', () => {
+    const wrapper = mountComponent()
+    expect(wrapper.find('select').exists()).toBe(true)
+  })
+
+  it('should render template options from $templates', () => {
+    const wrapper = mountComponent()
+    const options = wrapper.findAll('option')
+    expect(options.some(o => o.text().includes('template-A.json'))).toBe(true)
+    expect(options.some(o => o.text().includes('template-B.json'))).toBe(true)
+  })
+
+  it('should render number of stations input', () => {
+    const wrapper = mountComponent()
+    expect(wrapper.find('#number-of-stations').exists()).toBe(true)
+  })
+
+  it('should render supervision URL input', () => {
+    const wrapper = mountComponent()
+    expect(wrapper.find('#supervision-url').exists()).toBe(true)
+  })
+
+  it('should call addChargingStations on button click', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.addChargingStations).toHaveBeenCalled()
+  })
+
+  it('should navigate to charging-stations on success', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
+  })
+
+  it('should show error toast on failure', async () => {
+    const wrapper = mountComponent()
+    mockClient.addChargingStations = vi.fn().mockRejectedValue(new Error('Network error'))
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(toastMock.error).toHaveBeenCalled()
+  })
+
+  it('should render option checkboxes', () => {
+    const wrapper = mountComponent()
+    const checkboxes = wrapper.findAll('input[type="checkbox"]')
+    expect(checkboxes.length).toBe(4)
+  })
+
+  it('should pass supervision URL option when provided', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('#supervision-url').setValue('wss://custom-server.com')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.objectContaining({ supervisionUrls: 'wss://custom-server.com' })
+    )
+  })
+
+  it('should not pass supervision URL option when empty', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.addChargingStations).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.anything(),
+      expect.objectContaining({ supervisionUrls: undefined })
+    )
+  })
+})
diff --git a/ui/web/tests/unit/CSConnector.test.ts b/ui/web/tests/unit/CSConnector.test.ts
new file mode 100644 (file)
index 0000000..84ecb52
--- /dev/null
@@ -0,0 +1,204 @@
+/**
+ * @file Tests for CSConnector component
+ * @description Unit tests for connector row display, transaction actions, and ATG controls.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { UIClient } from '@/composables/UIClient'
+
+import CSConnector from '@/components/charging-stations/CSConnector.vue'
+import { useUIClient } from '@/composables'
+import { OCPP16ChargePointStatus } from '@/types/ChargingStationType'
+
+import { toastMock } from '../setup'
+import { createConnectorStatus, TEST_HASH_ID, TEST_STATION_ID } from './constants'
+import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+
+vi.mock('@/composables', async importOriginal => {
+  const actual = await importOriginal()
+  return { ...(actual as Record<string, unknown>), useUIClient: vi.fn() }
+})
+
+/**
+ * Mounts CSConnector with mock UIClient and Button stub.
+ * @param overrideProps - Props to override defaults
+ * @returns Mounted component wrapper
+ */
+function mountCSConnector (overrideProps: Record<string, unknown> = {}) {
+  return mount(CSConnector, {
+    global: {
+      stubs: {
+        Button: ButtonStub,
+        ToggleButton: true,
+      },
+    },
+    props: {
+      chargingStationId: TEST_STATION_ID,
+      connector: createConnectorStatus(),
+      connectorId: 1,
+      hashId: TEST_HASH_ID,
+      ...overrideProps,
+    },
+  })
+}
+
+describe('CSConnector', () => {
+  let mockClient: MockUIClient
+
+  beforeEach(() => {
+    mockClient = createMockUIClient()
+    vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient)
+  })
+
+  describe('connector display', () => {
+    it('should display connector ID without EVSE prefix', () => {
+      const wrapper = mountCSConnector()
+      const cells = wrapper.findAll('td')
+      expect(cells[0].text()).toBe('1')
+    })
+
+    it('should display connector ID with EVSE prefix when evseId provided', () => {
+      const wrapper = mountCSConnector({ evseId: 2 })
+      const cells = wrapper.findAll('td')
+      expect(cells[0].text()).toBe('2/1')
+    })
+
+    it('should display connector status', () => {
+      const wrapper = mountCSConnector({
+        connector: createConnectorStatus({ status: OCPP16ChargePointStatus.CHARGING }),
+      })
+      const cells = wrapper.findAll('td')
+      expect(cells[1].text()).toBe('Charging')
+    })
+
+    it('should display Ø when connector status is undefined', () => {
+      const wrapper = mountCSConnector({
+        connector: createConnectorStatus({ status: undefined }),
+      })
+      const cells = wrapper.findAll('td')
+      expect(cells[1].text()).toBe('Ø')
+    })
+
+    it('should display No when transaction not started', () => {
+      const wrapper = mountCSConnector()
+      const cells = wrapper.findAll('td')
+      expect(cells[2].text()).toBe('No')
+    })
+
+    it('should display Yes with transaction ID when transaction started', () => {
+      const wrapper = mountCSConnector({
+        connector: createConnectorStatus({ transactionId: 12345, transactionStarted: true }),
+      })
+      const cells = wrapper.findAll('td')
+      expect(cells[2].text()).toBe('Yes (12345)')
+    })
+
+    it('should display ATG started as Yes when active', () => {
+      const wrapper = mountCSConnector({ atgStatus: { start: true } })
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('Yes')
+    })
+
+    it('should display ATG started as No when not active', () => {
+      const wrapper = mountCSConnector({ atgStatus: { start: false } })
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('No')
+    })
+
+    it('should display ATG started as No when atgStatus undefined', () => {
+      const wrapper = mountCSConnector()
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('No')
+    })
+  })
+
+  describe('transaction actions', () => {
+    it('should call stopTransaction with correct params', async () => {
+      const connector = createConnectorStatus({ transactionId: 12345, transactionStarted: true })
+      const wrapper = mountCSConnector({ connector })
+      const buttons = wrapper.findAll('button')
+      const stopBtn = buttons.find(b => b.text() === 'Stop Transaction')
+      await stopBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.stopTransaction).toHaveBeenCalledWith(TEST_HASH_ID, {
+        ocppVersion: undefined,
+        transactionId: 12345,
+      })
+    })
+
+    it('should show error toast when no transaction to stop', async () => {
+      const connector = createConnectorStatus({ transactionId: undefined })
+      const wrapper = mountCSConnector({ connector })
+      const buttons = wrapper.findAll('button')
+      const stopBtn = buttons.find(b => b.text() === 'Stop Transaction')
+      await stopBtn?.trigger('click')
+      expect(toastMock.error).toHaveBeenCalledWith('No transaction to stop')
+    })
+
+    it('should show success toast after stopping transaction', async () => {
+      const connector = createConnectorStatus({ transactionId: 99, transactionStarted: true })
+      const wrapper = mountCSConnector({ connector })
+      const buttons = wrapper.findAll('button')
+      const stopBtn = buttons.find(b => b.text() === 'Stop Transaction')
+      await stopBtn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.success).toHaveBeenCalledWith('Transaction successfully stopped')
+    })
+  })
+
+  describe('ATG actions', () => {
+    it('should call startAutomaticTransactionGenerator', async () => {
+      const wrapper = mountCSConnector()
+      const buttons = wrapper.findAll('button')
+      const startAtgBtn = buttons.find(b => b.text() === 'Start ATG')
+      await startAtgBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.startAutomaticTransactionGenerator).toHaveBeenCalledWith(TEST_HASH_ID, 1)
+    })
+
+    it('should call stopAutomaticTransactionGenerator', async () => {
+      const wrapper = mountCSConnector()
+      const buttons = wrapper.findAll('button')
+      const stopAtgBtn = buttons.find(b => b.text() === 'Stop ATG')
+      await stopAtgBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.stopAutomaticTransactionGenerator).toHaveBeenCalledWith(TEST_HASH_ID, 1)
+    })
+
+    it('should show error toast on ATG start failure', async () => {
+      mockClient.startAutomaticTransactionGenerator.mockRejectedValueOnce(new Error('fail'))
+      const wrapper = mountCSConnector()
+      const buttons = wrapper.findAll('button')
+      const startAtgBtn = buttons.find(b => b.text() === 'Start ATG')
+      await startAtgBtn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith(
+        'Error at starting automatic transaction generator'
+      )
+    })
+
+    it('should show success toast when ATG started', async () => {
+      const wrapper = mountCSConnector()
+      const buttons = wrapper.findAll('button')
+      const btn = buttons.find(b => b.text().includes('Start ATG'))
+      await btn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.success).toHaveBeenCalledWith(
+        'Automatic transaction generator successfully started'
+      )
+    })
+
+    it('should show error toast when ATG stop fails', async () => {
+      mockClient.stopAutomaticTransactionGenerator.mockRejectedValueOnce(new Error('fail'))
+      const wrapper = mountCSConnector()
+      const buttons = wrapper.findAll('button')
+      const btn = buttons.find(b => b.text().includes('Stop ATG'))
+      await btn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith(
+        'Error at stopping automatic transaction generator'
+      )
+    })
+  })
+})
diff --git a/ui/web/tests/unit/CSData.test.ts b/ui/web/tests/unit/CSData.test.ts
new file mode 100644 (file)
index 0000000..2d3f464
--- /dev/null
@@ -0,0 +1,299 @@
+/**
+ * @file Tests for CSData component
+ * @description Unit tests for charging station row display, actions, and connector entry generation.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { UIClient } from '@/composables/UIClient'
+import type { ChargingStationData } from '@/types'
+
+import CSConnector from '@/components/charging-stations/CSConnector.vue'
+import CSData from '@/components/charging-stations/CSData.vue'
+import { useUIClient } from '@/composables'
+import { OCPPVersion } from '@/types'
+
+import { toastMock } from '../setup'
+import {
+  createChargingStationData,
+  createConnectorStatus,
+  createEvseEntry,
+  createStationInfo,
+} from './constants'
+import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+
+vi.mock('@/composables', async importOriginal => {
+  const actual = await importOriginal()
+  return { ...(actual as Record<string, unknown>), useUIClient: vi.fn() }
+})
+
+/**
+ * Mounts CSData with mock UIClient and stubbed child components.
+ * @param chargingStation - Charging station data
+ * @returns Mounted component wrapper
+ */
+function mountCSData (chargingStation: ChargingStationData = createChargingStationData()) {
+  return mount(CSData, {
+    global: {
+      stubs: {
+        Button: ButtonStub,
+        CSConnector: true,
+        ToggleButton: true,
+      },
+    },
+    props: { chargingStation },
+  })
+}
+
+describe('CSData', () => {
+  let mockClient: MockUIClient
+
+  beforeEach(() => {
+    mockClient = createMockUIClient()
+    vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient)
+  })
+
+  describe('station info display', () => {
+    it('should display charging station ID', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('CS-TEST-001')
+    })
+
+    it('should display started status as Yes when started', () => {
+      const wrapper = mountCSData(createChargingStationData({ started: true }))
+      const cells = wrapper.findAll('td')
+      expect(cells[1].text()).toBe('Yes')
+    })
+
+    it('should display started status as No when not started', () => {
+      const wrapper = mountCSData(createChargingStationData({ started: false }))
+      const cells = wrapper.findAll('td')
+      expect(cells[1].text()).toBe('No')
+    })
+
+    it('should display template name', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('template-test.json')
+    })
+
+    it('should display vendor and model', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('TestVendor')
+      expect(wrapper.text()).toContain('TestModel')
+    })
+
+    it('should display firmware version', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('1.0.0')
+    })
+
+    it('should display Ø when firmware version is missing', () => {
+      const station = createChargingStationData({
+        stationInfo: createStationInfo({ firmwareVersion: undefined }),
+      })
+      const wrapper = mountCSData(station)
+      const cells = wrapper.findAll('td')
+      expect(cells[9].text()).toBe('Ø')
+    })
+
+    it('should display WebSocket state as Open when OPEN', () => {
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.OPEN }))
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('Open')
+    })
+
+    it('should display WebSocket state as Closed when CLOSED', () => {
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.CLOSED }))
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('Closed')
+    })
+
+    it('should display WebSocket state as Ø for undefined state', () => {
+      const wrapper = mountCSData(createChargingStationData({ wsState: undefined }))
+      const cells = wrapper.findAll('td')
+      expect(cells[3].text()).toBe('Ø')
+    })
+
+    it('should display registration status', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('Accepted')
+    })
+
+    it('should display Ø when no boot notification response', () => {
+      const station = createChargingStationData({ bootNotificationResponse: undefined })
+      const wrapper = mountCSData(station)
+      const cells = wrapper.findAll('td')
+      expect(cells[4].text()).toBe('Ø')
+    })
+
+    it('should display WebSocket state as Connecting when CONNECTING', () => {
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.CONNECTING }))
+      expect(wrapper.text()).toContain('Connecting')
+    })
+
+    it('should display WebSocket state as Closing when CLOSING', () => {
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.CLOSING }))
+      expect(wrapper.text()).toContain('Closing')
+    })
+  })
+
+  describe('supervision URL display', () => {
+    it('should format supervision URL without path', () => {
+      const wrapper = mountCSData()
+      expect(wrapper.text()).toContain('ws://')
+      expect(wrapper.text()).toContain('supervisor')
+    })
+
+    it('should insert zero-width space after dots in host', () => {
+      const station = createChargingStationData({
+        supervisionUrl: 'ws://my.host.example.com:9000/path',
+      })
+      const wrapper = mountCSData(station)
+      const cells = wrapper.findAll('td')
+      const supervisionText = cells[2].text()
+      expect(supervisionText).toContain('\u200b')
+    })
+  })
+
+  describe('station actions', () => {
+    it('should call startChargingStation on button click', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
+      await startBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.startChargingStation).toHaveBeenCalledWith('test-hash-id-abc123')
+    })
+
+    it('should call stopChargingStation on button click', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const stopBtn = buttons.find(b => b.text() === 'Stop Charging Station')
+      await stopBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.stopChargingStation).toHaveBeenCalledWith('test-hash-id-abc123')
+    })
+
+    it('should call openConnection on button click', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const openBtn = buttons.find(b => b.text() === 'Open Connection')
+      await openBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.openConnection).toHaveBeenCalledWith('test-hash-id-abc123')
+    })
+
+    it('should call closeConnection on button click', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const closeBtn = buttons.find(b => b.text() === 'Close Connection')
+      await closeBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.closeConnection).toHaveBeenCalledWith('test-hash-id-abc123')
+    })
+
+    it('should call deleteChargingStation on button click', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const deleteBtn = buttons.find(b => b.text() === 'Delete Charging Station')
+      await deleteBtn?.trigger('click')
+      await flushPromises()
+      expect(mockClient.deleteChargingStation).toHaveBeenCalledWith('test-hash-id-abc123')
+    })
+
+    it('should show success toast after starting charging station', async () => {
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
+      await startBtn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.success).toHaveBeenCalledWith('Charging station successfully started')
+    })
+
+    it('should show error toast on start failure', async () => {
+      mockClient.startChargingStation.mockRejectedValueOnce(new Error('fail'))
+      const wrapper = mountCSData()
+      const buttons = wrapper.findAll('button')
+      const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
+      await startBtn?.trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at starting charging station')
+    })
+
+    it('should clean localStorage entries for deleted station', async () => {
+      const stationData = createChargingStationData()
+      const hashId = stationData.stationInfo.hashId
+      localStorage.setItem(`toggle-button-${hashId}-test`, 'true')
+      localStorage.setItem(`shared-toggle-button-${hashId}-other`, 'false')
+      localStorage.setItem('unrelated-key', 'keep')
+      const wrapper = mountCSData(stationData)
+      const buttons = wrapper.findAll('button')
+      const deleteBtn = buttons.find(b => b.text().includes('Delete'))
+      await deleteBtn?.trigger('click')
+      await flushPromises()
+      expect(localStorage.getItem(`toggle-button-${hashId}-test`)).toBeNull()
+      expect(localStorage.getItem(`shared-toggle-button-${hashId}-other`)).toBeNull()
+      expect(localStorage.getItem('unrelated-key')).toBe('keep')
+    })
+  })
+
+  describe('connector entries', () => {
+    it('should generate entries from connectors array for OCPP 1.6', () => {
+      const station = createChargingStationData({
+        connectors: [
+          { connector: createConnectorStatus(), connectorId: 0 },
+          { connector: createConnectorStatus(), connectorId: 1 },
+          { connector: createConnectorStatus(), connectorId: 2 },
+        ],
+      })
+      const wrapper = mountCSData(station)
+      expect(wrapper.findAllComponents(CSConnector)).toHaveLength(2)
+    })
+
+    it('should filter out connector 0', () => {
+      const station = createChargingStationData({
+        connectors: [{ connector: createConnectorStatus(), connectorId: 0 }],
+      })
+      const wrapper = mountCSData(station)
+      expect(wrapper.findAllComponents(CSConnector)).toHaveLength(0)
+    })
+
+    it('should generate entries from EVSEs array for OCPP 2.0.x', () => {
+      const station = createChargingStationData({
+        connectors: [],
+        evses: [
+          createEvseEntry({
+            connectors: [{ connector: createConnectorStatus(), connectorId: 0 }],
+            evseId: 0,
+          }),
+          createEvseEntry({
+            connectors: [{ connector: createConnectorStatus(), connectorId: 1 }],
+            evseId: 1,
+          }),
+          createEvseEntry({
+            connectors: [{ connector: createConnectorStatus(), connectorId: 1 }],
+            evseId: 2,
+          }),
+        ],
+        stationInfo: createStationInfo({ ocppVersion: OCPPVersion.VERSION_201 }),
+      })
+      const wrapper = mountCSData(station)
+      expect(wrapper.findAllComponents(CSConnector)).toHaveLength(2)
+    })
+
+    it('should filter out EVSE 0', () => {
+      const station = createChargingStationData({
+        connectors: [],
+        evses: [
+          createEvseEntry({
+            connectors: [{ connector: createConnectorStatus(), connectorId: 0 }],
+            evseId: 0,
+          }),
+        ],
+        stationInfo: createStationInfo({ ocppVersion: OCPPVersion.VERSION_201 }),
+      })
+      const wrapper = mountCSData(station)
+      expect(wrapper.findAllComponents(CSConnector)).toHaveLength(0)
+    })
+  })
+})
index ba8c8c258aa14a2198acc4cebbd9a918a9f36dbb..f215e1e798bbd4ed379127da3be67d943f8337a6 100644 (file)
@@ -1,24 +1,84 @@
-import { shallowMount } from '@vue/test-utils'
-import { expect, test } from 'vitest'
+/**
+ * @file Tests for CSTable component
+ * @description Unit tests for charging station table column headers and row rendering.
+ */
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
 
 import type { ChargingStationData } from '@/types'
 
 import CSTable from '@/components/charging-stations/CSTable.vue'
 
-test('renders CS table columns name', () => {
-  const chargingStations: ChargingStationData[] = []
-  const wrapper = shallowMount(CSTable, {
-    props: { chargingStations, idTag: '0' },
+import { createChargingStationData, createStationInfo } from './constants'
+
+/**
+ * Mounts CSTable with CSData stubbed out.
+ * @param chargingStations - Array of charging stations
+ * @returns Mounted component wrapper
+ */
+function mountCSTable (chargingStations: ChargingStationData[] = []) {
+  return mount(CSTable, {
+    global: { stubs: { CSData: true } },
+    props: { chargingStations },
+  })
+}
+
+describe('CSTable', () => {
+  describe('column headers', () => {
+    it('should render all column headers', () => {
+      const wrapper = mountCSTable()
+      const text = wrapper.text()
+      expect(text).toContain('Name')
+      expect(text).toContain('Started')
+      expect(text).toContain('Supervision Url')
+      expect(text).toContain('WebSocket State')
+      expect(text).toContain('Registration Status')
+      expect(text).toContain('OCPP Version')
+      expect(text).toContain('Template')
+      expect(text).toContain('Vendor')
+      expect(text).toContain('Model')
+      expect(text).toContain('Firmware')
+      expect(text).toContain('Actions')
+      expect(text).toContain('Connector(s)')
+    })
+
+    it('should render table caption', () => {
+      const wrapper = mountCSTable()
+      expect(wrapper.text()).toContain('Charging Stations')
+    })
+  })
+
+  describe('row rendering', () => {
+    it('should render a CSData row for each charging station', () => {
+      const stations = [
+        createChargingStationData(),
+        createChargingStationData({
+          stationInfo: createStationInfo({ chargingStationId: 'CS-002', hashId: 'hash-2' }),
+        }),
+      ]
+      const wrapper = mountCSTable(stations)
+      expect(wrapper.findAllComponents({ name: 'CSData' })).toHaveLength(2)
+    })
+
+    it('should handle empty charging stations array', () => {
+      const wrapper = mountCSTable([])
+      expect(wrapper.findAllComponents({ name: 'CSData' })).toHaveLength(0)
+    })
+
+    it('should propagate need-refresh event from CSData', async () => {
+      const stations = [createChargingStationData()]
+      const CSDataStub = {
+        emits: ['need-refresh'],
+        template: '<tr></tr>',
+      }
+      const wrapper = mount(CSTable, {
+        global: { stubs: { CSData: CSDataStub } },
+        props: { chargingStations: stations },
+      })
+      const csDataComponent = wrapper.findComponent(CSDataStub)
+      // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
+      await csDataComponent.vm.$emit('need-refresh')
+      expect(wrapper.emitted('need-refresh')).toHaveLength(1)
+    })
   })
-  expect(wrapper.text()).to.include('Name')
-  expect(wrapper.text()).to.include('Started')
-  expect(wrapper.text()).to.include('Supervision Url')
-  expect(wrapper.text()).to.include('WebSocket State')
-  expect(wrapper.text()).to.include('Registration Status')
-  expect(wrapper.text()).to.include('Template')
-  expect(wrapper.text()).to.include('Vendor')
-  expect(wrapper.text()).to.include('Model')
-  expect(wrapper.text()).to.include('Firmware')
-  expect(wrapper.text()).to.include('Actions')
-  expect(wrapper.text()).to.include('Connector(s)')
 })
diff --git a/ui/web/tests/unit/ChargingStationsView.test.ts b/ui/web/tests/unit/ChargingStationsView.test.ts
new file mode 100644 (file)
index 0000000..b57c32f
--- /dev/null
@@ -0,0 +1,417 @@
+/**
+ * @file Tests for ChargingStationsView component
+ * @description Unit tests for the main view: WS event listeners, data fetching,
+ *   simulator state display, CSTable visibility, UI server selector, and error handling.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { ref } from 'vue'
+
+import type { UIClient } from '@/composables/UIClient'
+
+import { useUIClient } from '@/composables'
+import { ResponseStatus } from '@/types'
+import ChargingStationsView from '@/views/ChargingStationsView.vue'
+
+import { toastMock } from '../setup'
+import { createChargingStationData, createUIServerConfig } from './constants'
+import { createMockUIClient, type MockUIClient } from './helpers'
+
+vi.mock('@/composables', async importOriginal => {
+  const actual = await importOriginal()
+  return { ...(actual as Record<string, unknown>), useUIClient: vi.fn() }
+})
+
+// ── Configuration fixtures ────────────────────────────────────────────────────
+
+const singleServerConfig = {
+  uiServer: [createUIServerConfig()],
+}
+
+const multiServerConfig = {
+  uiServer: [
+    createUIServerConfig({ name: 'Server 1' }),
+    createUIServerConfig({ host: 'server2', name: 'Server 2' }),
+  ],
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+let mockClient: MockUIClient
+
+/**
+ * Extracts the registered WS event handler for a given event type.
+ * @param eventType - WS event name (open, error, close)
+ * @returns The registered handler function or undefined
+ */
+function getWSHandler (eventType: string): ((...args: unknown[]) => void) | undefined {
+  const call = vi
+    .mocked(mockClient.registerWSEventListener)
+    .mock.calls.find(([event]) => event === eventType)
+  return call?.[1] as ((...args: unknown[]) => void) | undefined
+}
+
+/**
+ * Mounts ChargingStationsView with mock UIClient and global properties.
+ * Uses transparent Container stub so v-show directives work correctly.
+ * @param options - Mount configuration overrides
+ * @param options.chargingStations - Initial charging stations data
+ * @param options.configuration - UI server configuration (single or multi)
+ * @param options.templates - Template names
+ * @returns Mounted component wrapper
+ */
+function mountView (
+  options: {
+    chargingStations?: ReturnType<typeof createChargingStationData>[]
+    configuration?: typeof multiServerConfig | typeof singleServerConfig
+    templates?: string[]
+  } = {}
+) {
+  const { chargingStations = [], configuration = singleServerConfig, templates = [] } = options
+
+  return mount(ChargingStationsView, {
+    global: {
+      config: {
+        globalProperties: {
+          $chargingStations: ref(chargingStations),
+          $configuration: ref(configuration),
+          $route: { name: 'charging-stations', params: {}, query: {} },
+          $router: { back: vi.fn(), push: vi.fn(), replace: vi.fn() },
+          $templates: ref(templates),
+          $uiClient: mockClient,
+        } as never,
+      },
+      stubs: {
+        Container: { name: 'Container', template: '<div><slot /></div>' },
+        CSTable: true,
+        ReloadButton: {
+          emits: ['click'],
+          name: 'ReloadButton',
+          props: ['loading'],
+          template: '<button @click="$emit(\'click\')" />',
+        },
+        ToggleButton: {
+          name: 'ToggleButton',
+          props: ['id', 'on', 'off', 'status', 'shared'],
+          template: '<button><slot /></button>',
+        },
+      },
+    },
+  })
+}
+
+/**
+ * Triggers the 'open' WS event handler to simulate a connection open (calls getData).
+ */
+async function triggerWSOpen (): Promise<void> {
+  const openHandler = getWSHandler('open')
+  openHandler?.()
+  await flushPromises()
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('ChargingStationsView', () => {
+  beforeEach(() => {
+    mockClient = createMockUIClient()
+    vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient)
+  })
+
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  describe('WebSocket event listeners', () => {
+    it('should register open, error, close listeners on mount', () => {
+      mountView()
+      expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('open', expect.any(Function))
+      expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('error', expect.any(Function))
+      expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('close', expect.any(Function))
+    })
+
+    it('should register exactly 3 listeners', () => {
+      mountView()
+      expect(mockClient.registerWSEventListener).toHaveBeenCalledTimes(3)
+    })
+
+    it('should unregister open, error, close listeners on unmount', () => {
+      const wrapper = mountView()
+      wrapper.unmount()
+      expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith(
+        'open',
+        expect.any(Function)
+      )
+      expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith(
+        'error',
+        expect.any(Function)
+      )
+      expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith(
+        'close',
+        expect.any(Function)
+      )
+    })
+
+    it('should unregister exactly 3 listeners on unmount', () => {
+      const wrapper = mountView()
+      wrapper.unmount()
+      expect(mockClient.unregisterWSEventListener).toHaveBeenCalledTimes(3)
+    })
+  })
+
+  describe('getData on WS open', () => {
+    it('should call simulatorState when WS opens', async () => {
+      mountView()
+      await triggerWSOpen()
+      expect(mockClient.simulatorState).toHaveBeenCalled()
+    })
+
+    it('should call listTemplates when WS opens', async () => {
+      mountView()
+      await triggerWSOpen()
+      expect(mockClient.listTemplates).toHaveBeenCalled()
+    })
+
+    it('should call listChargingStations when WS opens', async () => {
+      mountView()
+      await triggerWSOpen()
+      expect(mockClient.listChargingStations).toHaveBeenCalled()
+    })
+  })
+
+  describe('simulator state display', () => {
+    it('should show "Start Simulator" when simulator not started', async () => {
+      mockClient.simulatorState = vi.fn().mockResolvedValue({
+        state: { started: false, templateStatistics: {} },
+        status: ResponseStatus.SUCCESS,
+      })
+      const wrapper = mountView()
+      await triggerWSOpen()
+      expect(wrapper.text()).toContain('Start Simulator')
+    })
+
+    it('should show "Stop Simulator" with version when started', async () => {
+      mockClient.simulatorState = vi.fn().mockResolvedValue({
+        state: { started: true, templateStatistics: {}, version: '1.5.0' },
+        status: ResponseStatus.SUCCESS,
+      })
+      const wrapper = mountView()
+      await triggerWSOpen()
+      expect(wrapper.text()).toContain('Stop Simulator')
+      expect(wrapper.text()).toContain('1.5.0')
+    })
+
+    it('should show "Start Simulator" without version initially', () => {
+      const wrapper = mountView()
+      expect(wrapper.text()).toContain('Start Simulator')
+      expect(wrapper.text()).not.toContain('(')
+    })
+  })
+
+  describe('reload button', () => {
+    it('should call listChargingStations when reload button clicked', async () => {
+      const wrapper = mountView()
+      const reloadButton = wrapper.findComponent({ name: 'ReloadButton' })
+      await reloadButton.trigger('click')
+      await flushPromises()
+      expect(mockClient.listChargingStations).toHaveBeenCalled()
+    })
+  })
+
+  describe('CSTable visibility', () => {
+    it('should hide CSTable when no charging stations', () => {
+      const wrapper = mountView({ chargingStations: [] })
+      const csTable = wrapper.findComponent({ name: 'CSTable' })
+      expect(csTable.exists()).toBe(true)
+      expect((csTable.element as HTMLElement).style.display).toBe('none')
+    })
+
+    it('should show CSTable when charging stations exist', () => {
+      const wrapper = mountView({
+        chargingStations: [createChargingStationData()],
+      })
+      const csTable = wrapper.findComponent({ name: 'CSTable' })
+      expect(csTable.exists()).toBe(true)
+      expect((csTable.element as HTMLElement).style.display).not.toBe('none')
+    })
+  })
+
+  describe('UI server selector', () => {
+    it('should hide server selector for single server configuration', () => {
+      const wrapper = mountView({ configuration: singleServerConfig })
+      const selectorContainer = wrapper.find('#ui-server-container')
+      expect(selectorContainer.exists()).toBe(true)
+      expect((selectorContainer.element as HTMLElement).style.display).toBe('none')
+    })
+
+    it('should show server selector for multiple server configuration', () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const selectorContainer = wrapper.find('#ui-server-container')
+      expect(selectorContainer.exists()).toBe(true)
+      expect((selectorContainer.element as HTMLElement).style.display).not.toBe('none')
+    })
+
+    it('should render an option for each server', () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const options = wrapper.findAll('#ui-server-selector option')
+      expect(options).toHaveLength(2)
+    })
+
+    it('should display server name in options', () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const options = wrapper.findAll('#ui-server-selector option')
+      expect(options[0].text()).toContain('Server 1')
+      expect(options[1].text()).toContain('Server 2')
+    })
+
+    it('should fall back to host when server name is missing', () => {
+      const config = {
+        uiServer: [
+          createUIServerConfig({ host: 'host-a' }),
+          createUIServerConfig({ host: 'host-b' }),
+        ],
+      }
+      const wrapper = mountView({ configuration: config })
+      const options = wrapper.findAll('#ui-server-selector option')
+      expect(options[0].text()).toContain('host-a')
+      expect(options[1].text()).toContain('host-b')
+    })
+  })
+
+  describe('start/stop simulator', () => {
+    it('should show success toast when simulator starts', async () => {
+      mockClient.startSimulator = vi.fn().mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
+      const wrapper = mountView()
+      const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' })
+      const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator')
+      const onProp = simulatorButton?.props('on') as (() => void) | undefined
+      onProp?.()
+      await flushPromises()
+      expect(mockClient.startSimulator).toHaveBeenCalled()
+      expect(toastMock.success).toHaveBeenCalledWith('Simulator successfully started')
+    })
+
+    it('should show error toast when simulator start fails', async () => {
+      mockClient.startSimulator = vi.fn().mockRejectedValue(new Error('start failed'))
+      const wrapper = mountView()
+      const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' })
+      const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator')
+      const onProp = simulatorButton?.props('on') as (() => void) | undefined
+      onProp?.()
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at starting simulator')
+    })
+
+    it('should show success toast when simulator stops', async () => {
+      mockClient.stopSimulator = vi.fn().mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
+      const wrapper = mountView()
+      const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' })
+      const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator')
+      const offProp = simulatorButton?.props('off') as (() => void) | undefined
+      offProp?.()
+      await flushPromises()
+      expect(mockClient.stopSimulator).toHaveBeenCalled()
+      expect(toastMock.success).toHaveBeenCalledWith('Simulator successfully stopped')
+    })
+
+    it('should show error toast when simulator stop fails', async () => {
+      mockClient.stopSimulator = vi.fn().mockRejectedValue(new Error('stop failed'))
+      const wrapper = mountView()
+      const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' })
+      const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator')
+      const offProp = simulatorButton?.props('off') as (() => void) | undefined
+      offProp?.()
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at stopping simulator')
+    })
+  })
+
+  describe('error handling', () => {
+    it('should show error toast when listChargingStations fails', async () => {
+      mockClient.listChargingStations = vi.fn().mockRejectedValue(new Error('Network error'))
+      mountView()
+      await triggerWSOpen()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at fetching charging stations')
+    })
+
+    it('should show error toast when listTemplates fails', async () => {
+      mockClient.listTemplates = vi.fn().mockRejectedValue(new Error('Template error'))
+      mountView()
+      await triggerWSOpen()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at fetching charging station templates')
+    })
+
+    it('should show error toast when simulatorState fails', async () => {
+      mockClient.simulatorState = vi.fn().mockRejectedValue(new Error('State error'))
+      mountView()
+      await triggerWSOpen()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at fetching simulator state')
+    })
+  })
+
+  describe('server switching', () => {
+    it('should call setConfiguration when server index changes', async () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const selector = wrapper.find('#ui-server-selector')
+      await selector.setValue(1)
+      expect(mockClient.setConfiguration).toHaveBeenCalled()
+    })
+
+    it('should register new WS event listeners after server switch', async () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      // Reset call count from initial mount registration
+      vi.mocked(mockClient.registerWSEventListener).mockClear()
+      const selector = wrapper.find('#ui-server-selector')
+      await selector.setValue(1)
+      // registerWSEventListeners called again
+      expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('open', expect.any(Function))
+    })
+
+    it('should save server index to localStorage on successful switch', async () => {
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const selector = wrapper.find('#ui-server-selector')
+      await selector.setValue(1)
+      // Simulate the WS open for the new connection (once-listener from server switching)
+      const onceOpenCalls = vi
+        .mocked(mockClient.registerWSEventListener)
+        .mock.calls.filter(
+          ([event, , options]) =>
+            event === 'open' &&
+            (options as undefined | { once?: boolean }) != null &&
+            (options as { once?: boolean }).once === true
+        )
+      const onceOpenHandler = onceOpenCalls[onceOpenCalls.length - 1]?.[1] as
+        | (() => void)
+        | undefined
+      onceOpenHandler?.()
+      await flushPromises()
+      expect(localStorage.getItem('uiServerConfigurationIndex')).toBe('1')
+    })
+
+    it('should revert server index on connection error', async () => {
+      localStorage.setItem('uiServerConfigurationIndex', '0')
+      const wrapper = mountView({ configuration: multiServerConfig })
+      const selector = wrapper.find('#ui-server-selector')
+      await selector.setValue(1)
+      // Find the once-error listener
+      const onceErrorCalls = vi
+        .mocked(mockClient.registerWSEventListener)
+        .mock.calls.filter(
+          ([event, , options]) =>
+            event === 'error' &&
+            (options as undefined | { once?: boolean }) != null &&
+            (options as { once?: boolean }).once === true
+        )
+      const onceErrorHandler = onceErrorCalls[onceErrorCalls.length - 1]?.[1] as
+        | (() => void)
+        | undefined
+      onceErrorHandler?.()
+      await flushPromises()
+      // Should revert to index 0
+      expect(mockClient.setConfiguration).toHaveBeenCalledTimes(2)
+    })
+  })
+})
diff --git a/ui/web/tests/unit/SetSupervisionUrl.test.ts b/ui/web/tests/unit/SetSupervisionUrl.test.ts
new file mode 100644 (file)
index 0000000..b8bd56a
--- /dev/null
@@ -0,0 +1,83 @@
+/**
+ * @file Tests for SetSupervisionUrl component
+ * @description Unit tests for supervision URL form — display, submission, and navigation.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import SetSupervisionUrl from '@/components/actions/SetSupervisionUrl.vue'
+
+import { toastMock } from '../setup'
+import { TEST_HASH_ID, TEST_STATION_ID } from './constants'
+import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+
+describe('SetSupervisionUrl', () => {
+  let mockClient: MockUIClient
+  let mockRouter: { push: ReturnType<typeof vi.fn> }
+
+  /**
+   * Mounts SetSupervisionUrl with mock UIClient, router, and toast.
+   * @param props - Props to override defaults
+   * @returns Mounted component wrapper
+   */
+  function mountComponent (props = {}) {
+    mockClient = createMockUIClient()
+    mockRouter = { push: vi.fn() }
+    return mount(SetSupervisionUrl, {
+      global: {
+        config: {
+          globalProperties: {
+            $router: mockRouter,
+            $toast: toastMock,
+            $uiClient: mockClient,
+          } as never,
+        },
+        stubs: {
+          Button: ButtonStub,
+        },
+      },
+      props: {
+        chargingStationId: TEST_STATION_ID,
+        hashId: TEST_HASH_ID,
+        ...props,
+      },
+    })
+  }
+
+  it('should display the charging station ID', () => {
+    const wrapper = mountComponent()
+    expect(wrapper.text()).toContain(TEST_STATION_ID)
+  })
+
+  it('should render supervision URL input', () => {
+    const wrapper = mountComponent()
+    expect(wrapper.find('#supervision-url').exists()).toBe(true)
+  })
+
+  it('should call setSupervisionUrl on button click', async () => {
+    const wrapper = mountComponent()
+    const input = wrapper.find('#supervision-url')
+    await input.setValue('wss://new-server.com:9000')
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith(
+      TEST_HASH_ID,
+      'wss://new-server.com:9000'
+    )
+  })
+
+  it('should navigate to charging-stations after submission', async () => {
+    const wrapper = mountComponent()
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
+  })
+
+  it('should show error toast on failure', async () => {
+    const wrapper = mountComponent()
+    mockClient.setSupervisionUrl = vi.fn().mockRejectedValue(new Error('Network error'))
+    await wrapper.find('button').trigger('click')
+    await flushPromises()
+    expect(toastMock.error).toHaveBeenCalled()
+  })
+})
diff --git a/ui/web/tests/unit/SimpleComponents.test.ts b/ui/web/tests/unit/SimpleComponents.test.ts
new file mode 100644 (file)
index 0000000..aa0f4d1
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * @file Tests for simple presentational components
+ * @description Unit tests for Button, Container, ReloadButton, NotFoundView, and App.
+ */
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import { defineComponent } from 'vue'
+import { createMemoryHistory, createRouter } from 'vue-router'
+
+import App from '@/App.vue'
+import Button from '@/components/buttons/Button.vue'
+import ReloadButton from '@/components/buttons/ReloadButton.vue'
+import Container from '@/components/Container.vue'
+import NotFoundView from '@/views/NotFoundView.vue'
+
+const DummyComponent = defineComponent({ template: '<div />' })
+
+describe('Button', () => {
+  it('should render slot content', () => {
+    const wrapper = mount(Button, { slots: { default: 'Click me' } })
+    expect(wrapper.text()).toContain('Click me')
+  })
+
+  it('should render a button element', () => {
+    const wrapper = mount(Button)
+    expect(wrapper.find('button').exists()).toBe(true)
+  })
+})
+
+describe('Container', () => {
+  it('should render slot content', () => {
+    const wrapper = mount(Container, { slots: { default: 'Content' } })
+    expect(wrapper.text()).toContain('Content')
+  })
+
+  it('should apply container class', () => {
+    const wrapper = mount(Container)
+    expect(wrapper.classes()).toContain('container')
+  })
+})
+
+describe('ReloadButton', () => {
+  it('should render reload icon', () => {
+    const wrapper = mount(ReloadButton, { props: { loading: false } })
+    expect(wrapper.find('span').exists()).toBe(true)
+  })
+
+  it('should apply spin class when loading is true', () => {
+    const wrapper = mount(ReloadButton, { props: { loading: true } })
+    expect(wrapper.find('span').classes()).toContain('spin')
+  })
+
+  it('should not apply spin class when loading is false', () => {
+    const wrapper = mount(ReloadButton, { props: { loading: false } })
+    expect(wrapper.find('span').classes()).not.toContain('spin')
+  })
+})
+
+describe('NotFoundView', () => {
+  it('should render 404 message', () => {
+    const wrapper = mount(NotFoundView)
+    expect(wrapper.text()).toContain('404')
+    expect(wrapper.text()).toContain('Not found')
+  })
+})
+
+describe('App', () => {
+  it('should render Container component', async () => {
+    const router = createRouter({
+      history: createMemoryHistory(),
+      routes: [{ component: DummyComponent, name: 'charging-stations', path: '/' }],
+    })
+    await router.push('/')
+    await router.isReady()
+    const wrapper = mount(App, {
+      global: { plugins: [router] },
+    })
+    expect(wrapper.findComponent(Container).exists()).toBe(true)
+  })
+
+  it('should hide action container on charging-stations route', async () => {
+    const router = createRouter({
+      history: createMemoryHistory(),
+      routes: [{ component: DummyComponent, name: 'charging-stations', path: '/' }],
+    })
+    await router.push('/')
+    await router.isReady()
+    const wrapper = mount(App, {
+      global: { plugins: [router] },
+    })
+    const actionContainer = wrapper.find('#action-container')
+    // v-show hides via inline style display:none
+    const element = actionContainer.element as HTMLElement
+    expect(element.style.display).toBe('none')
+  })
+})
diff --git a/ui/web/tests/unit/StartTransaction.test.ts b/ui/web/tests/unit/StartTransaction.test.ts
new file mode 100644 (file)
index 0000000..26c4aaf
--- /dev/null
@@ -0,0 +1,192 @@
+/**
+ * @file Tests for StartTransaction component
+ * @description Unit tests for start transaction form — OCPP version branching, authorization flow, and navigation.
+ */
+import { flushPromises, mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import type { UIClient } from '@/composables/UIClient'
+
+import StartTransaction from '@/components/actions/StartTransaction.vue'
+import { useUIClient } from '@/composables'
+import { OCPPVersion } from '@/types'
+
+import { toastMock } from '../setup'
+import { TEST_HASH_ID, TEST_ID_TAG, TEST_STATION_ID } from './constants'
+import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+
+vi.mock('@/composables', async importOriginal => {
+  const actual: Record<string, unknown> = await importOriginal()
+  return { ...actual, useUIClient: vi.fn() }
+})
+
+vi.mock('vue-router', async importOriginal => {
+  const actual: Record<string, unknown> = await importOriginal()
+  return {
+    ...actual,
+    useRoute: vi.fn(),
+    useRouter: vi.fn(),
+  }
+})
+
+import { useRoute, useRouter } from 'vue-router'
+
+describe('StartTransaction', () => {
+  let mockClient: MockUIClient
+  let mockRouter: { push: ReturnType<typeof vi.fn> }
+
+  /**
+   * Mounts StartTransaction with mock UIClient, router, and route query.
+   * @param routeQuery - Route query parameters
+   * @returns Mounted component wrapper
+   */
+  function mountComponent (routeQuery: Record<string, string> = {}) {
+    mockClient = createMockUIClient()
+    mockRouter = { push: vi.fn() }
+    vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient)
+    vi.mocked(useRouter).mockReturnValue(mockRouter as unknown as ReturnType<typeof useRouter>)
+    vi.mocked(useRoute).mockReturnValue({
+      name: 'start-transaction',
+      params: {
+        chargingStationId: TEST_STATION_ID,
+        connectorId: '1',
+        hashId: TEST_HASH_ID,
+      },
+      query: routeQuery,
+    } as unknown as ReturnType<typeof useRoute>)
+    return mount(StartTransaction, {
+      global: {
+        stubs: {
+          Button: ButtonStub,
+        },
+      },
+      props: {
+        chargingStationId: TEST_STATION_ID,
+        connectorId: '1',
+        hashId: TEST_HASH_ID,
+      },
+    })
+  }
+
+  describe('display', () => {
+    it('should display charging station ID', () => {
+      const wrapper = mountComponent()
+      expect(wrapper.text()).toContain(TEST_STATION_ID)
+    })
+
+    it('should display connector ID without EVSE when no evseId', () => {
+      const wrapper = mountComponent()
+      expect(wrapper.text()).toContain('Connector 1')
+      expect(wrapper.text()).not.toContain('EVSE')
+    })
+
+    it('should display EVSE and connector when evseId in query', () => {
+      const wrapper = mountComponent({
+        evseId: '2',
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      expect(wrapper.text()).toContain('EVSE 2')
+    })
+
+    it('should show authorize checkbox for OCPP 1.6', () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 })
+      expect(wrapper.find('[type="checkbox"]').exists()).toBe(true)
+    })
+
+    it('should hide authorize checkbox for OCPP 2.0.x', () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_20 })
+      expect(wrapper.find('[type="checkbox"]').exists()).toBe(false)
+    })
+  })
+
+  describe('OCPP 1.6 transaction flow', () => {
+    it('should call startTransaction for OCPP 1.6', async () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 })
+      await wrapper.find('#idtag').setValue(TEST_ID_TAG)
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(mockClient.startTransaction).toHaveBeenCalledWith(TEST_HASH_ID, {
+        connectorId: 1,
+        evseId: undefined,
+        idTag: TEST_ID_TAG,
+        ocppVersion: OCPPVersion.VERSION_16,
+      })
+    })
+
+    it('should call authorize before startTransaction when authorize checked', async () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 })
+      await wrapper.find('#idtag').setValue(TEST_ID_TAG)
+      await wrapper.find('[type="checkbox"]').setValue(true)
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(mockClient.authorize).toHaveBeenCalledWith(TEST_HASH_ID, TEST_ID_TAG)
+      expect(mockClient.startTransaction).toHaveBeenCalled()
+    })
+
+    it('should show error toast when authorize checked but no idTag provided', async () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 })
+      await wrapper.find('[type="checkbox"]').setValue(true)
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Please provide an RFID tag to authorize')
+      expect(mockClient.startTransaction).not.toHaveBeenCalled()
+    })
+
+    it('should show error toast and navigate when authorize call fails', async () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 })
+      mockClient.authorize = vi.fn().mockRejectedValue(new Error('Auth failed'))
+      await wrapper.find('#idtag').setValue(TEST_ID_TAG)
+      await wrapper.find('[type="checkbox"]').setValue(true)
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalledWith('Error at authorizing RFID tag')
+      expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
+      expect(mockClient.startTransaction).not.toHaveBeenCalled()
+    })
+  })
+
+  describe('OCPP 2.0.x transaction flow', () => {
+    it('should call startTransaction for OCPP 2.0.x', async () => {
+      const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_20 })
+      await wrapper.find('#idtag').setValue(TEST_ID_TAG)
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(mockClient.startTransaction).toHaveBeenCalledWith(TEST_HASH_ID, {
+        connectorId: 1,
+        evseId: undefined,
+        idTag: TEST_ID_TAG,
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+    })
+
+    it('should pass evseId when available', async () => {
+      const wrapper = mountComponent({
+        evseId: '2',
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(mockClient.startTransaction).toHaveBeenCalledWith(
+        TEST_HASH_ID,
+        expect.objectContaining({ evseId: 2 })
+      )
+    })
+  })
+
+  describe('navigation', () => {
+    it('should navigate back after transaction', async () => {
+      const wrapper = mountComponent()
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' })
+    })
+
+    it('should show error toast on failure', async () => {
+      const wrapper = mountComponent()
+      mockClient.startTransaction = vi.fn().mockRejectedValue(new Error('Failed'))
+      await wrapper.find('button').trigger('click')
+      await flushPromises()
+      expect(toastMock.error).toHaveBeenCalled()
+    })
+  })
+})
diff --git a/ui/web/tests/unit/ToggleButton.test.ts b/ui/web/tests/unit/ToggleButton.test.ts
new file mode 100644 (file)
index 0000000..2314e7e
--- /dev/null
@@ -0,0 +1,237 @@
+/**
+ * @file Tests for ToggleButton component
+ * @description Unit tests for toggle state, localStorage persistence, shared toggle behavior, and callbacks.
+ */
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import ToggleButton from '@/components/buttons/ToggleButton.vue'
+
+/**
+ * Mount factory — stubs Button child component with slot passthrough
+ * @param props - Component props
+ * @param props.id - Unique identifier for the toggle
+ * @param props.off - Callback when toggled off
+ * @param props.on - Callback when toggled on
+ * @param props.shared - Whether this is a shared toggle
+ * @param props.status - Initial toggle status
+ * @returns Mounted wrapper
+ */
+function mountToggleButton (
+  props: {
+    id: string
+    off?: () => void
+    on?: () => void
+    shared?: boolean
+    status?: boolean
+  } = { id: 'test-toggle' }
+) {
+  return mount(ToggleButton, {
+    global: {
+      stubs: {
+        Button: {
+          template: '<button class="button" type="button"><slot /></button>',
+        },
+      },
+    },
+    props,
+    slots: { default: 'Toggle' },
+  })
+}
+
+describe('ToggleButton', () => {
+  describe('rendering', () => {
+    it('should render slot content', () => {
+      const wrapper = mountToggleButton({ id: 'render-test' })
+      expect(wrapper.text()).toContain('Toggle')
+    })
+
+    it('should not apply on class when status is false', () => {
+      const wrapper = mountToggleButton({ id: 'off-test', status: false })
+      const button = wrapper.find('button')
+      expect(button.classes()).not.toContain('on')
+    })
+
+    it('should apply on class when status is true', () => {
+      const wrapper = mountToggleButton({ id: 'on-test', status: true })
+      const button = wrapper.find('button')
+      expect(button.classes()).toContain('on')
+    })
+  })
+
+  describe('toggle behavior', () => {
+    it('should toggle from inactive to active on click', async () => {
+      const wrapper = mountToggleButton({ id: 'toggle-inactive-to-active', status: false })
+      const button = wrapper.find('button')
+
+      expect(button.classes()).not.toContain('on')
+      await button.trigger('click')
+      expect(button.classes()).toContain('on')
+    })
+
+    it('should toggle from active to inactive on click', async () => {
+      const wrapper = mountToggleButton({ id: 'toggle-active-to-inactive', status: true })
+      const button = wrapper.find('button')
+
+      expect(button.classes()).toContain('on')
+      await button.trigger('click')
+      expect(button.classes()).not.toContain('on')
+    })
+
+    it('should call on callback when toggled to active', async () => {
+      const onCallback = vi.fn()
+      const wrapper = mountToggleButton({
+        id: 'on-callback-test',
+        off: vi.fn(),
+        on: onCallback,
+        status: false,
+      })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(onCallback).toHaveBeenCalledOnce()
+    })
+
+    it('should call off callback when toggled to inactive', async () => {
+      const offCallback = vi.fn()
+      const wrapper = mountToggleButton({
+        id: 'off-callback-test',
+        off: offCallback,
+        on: vi.fn(),
+        status: true,
+      })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(offCallback).toHaveBeenCalledOnce()
+    })
+
+    it('should emit clicked event with new boolean state', async () => {
+      const wrapper = mountToggleButton({ id: 'emit-test', status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(wrapper.emitted('clicked')?.[0]).toEqual([true])
+    })
+
+    it('should emit clicked event with false when toggling off', async () => {
+      const wrapper = mountToggleButton({ id: 'emit-false-test', status: true })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(wrapper.emitted('clicked')?.[0]).toEqual([false])
+    })
+  })
+
+  describe('localStorage persistence', () => {
+    it('should save toggle state to localStorage on click', async () => {
+      const wrapper = mountToggleButton({ id: 'persist-test', status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(localStorage.getItem('toggle-button-persist-test')).toBe('true')
+    })
+
+    it('should restore toggle state from localStorage on mount', () => {
+      localStorage.setItem('toggle-button-restore-test', 'true')
+      const wrapper = mountToggleButton({ id: 'restore-test', status: false })
+      const button = wrapper.find('button')
+
+      expect(button.classes()).toContain('on')
+    })
+
+    it('should use correct localStorage key for non-shared toggle', async () => {
+      const wrapper = mountToggleButton({ id: 'key-test', shared: false, status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(localStorage.getItem('toggle-button-key-test')).toBe('true')
+      expect(localStorage.getItem('shared-toggle-button-key-test')).toBeNull()
+    })
+
+    it('should use correct localStorage key for shared toggle', async () => {
+      const wrapper = mountToggleButton({ id: 'shared-key-test', shared: true, status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(localStorage.getItem('shared-toggle-button-shared-key-test')).toBe('true')
+      expect(localStorage.getItem('toggle-button-shared-key-test')).toBeNull()
+    })
+  })
+
+  describe('shared toggle behavior', () => {
+    it('should reset other shared toggles when activated', async () => {
+      localStorage.setItem('shared-toggle-button-other', 'true')
+
+      const wrapper = mountToggleButton({ id: 'mine', shared: true, status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+
+      expect(localStorage.getItem('shared-toggle-button-other')).toBe('false')
+    })
+
+    it('should not reset non-shared toggles when shared toggle is activated', async () => {
+      localStorage.setItem('toggle-button-other', 'true')
+
+      const wrapper = mountToggleButton({ id: 'shared-mine', shared: true, status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+
+      expect(localStorage.getItem('toggle-button-other')).toBe('true')
+    })
+
+    it('should reset multiple other shared toggles when activated', async () => {
+      localStorage.setItem('shared-toggle-button-first', 'true')
+      localStorage.setItem('shared-toggle-button-second', 'true')
+
+      const wrapper = mountToggleButton({ id: 'third', shared: true, status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+
+      expect(localStorage.getItem('shared-toggle-button-first')).toBe('false')
+      expect(localStorage.getItem('shared-toggle-button-second')).toBe('false')
+    })
+  })
+
+  describe('edge cases', () => {
+    it('should handle missing on callback gracefully', async () => {
+      const wrapper = mountToggleButton({ id: 'no-on-callback', off: vi.fn(), status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(wrapper.emitted('clicked')?.[0]).toEqual([true])
+    })
+
+    it('should handle missing off callback gracefully', async () => {
+      const wrapper = mountToggleButton({ id: 'no-off-callback', on: vi.fn(), status: true })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(wrapper.emitted('clicked')?.[0]).toEqual([false])
+    })
+
+    it('should use default status false when not provided', () => {
+      const wrapper = mountToggleButton({ id: 'default-status' })
+      const button = wrapper.find('button')
+
+      expect(button.classes()).not.toContain('on')
+    })
+
+    it('should handle multiple consecutive clicks', async () => {
+      const wrapper = mountToggleButton({ id: 'multi-click', status: false })
+      const button = wrapper.find('button')
+
+      await button.trigger('click')
+      expect(button.classes()).toContain('on')
+
+      await button.trigger('click')
+      expect(button.classes()).not.toContain('on')
+
+      await button.trigger('click')
+      expect(button.classes()).toContain('on')
+    })
+  })
+})
index 2f61ab84d07555c38c448d1e753bb065c2658f2e..41ec2b96a97f0bb4515384d0cc10fe2064d61acc 100644 (file)
@@ -1,49 +1,56 @@
+/**
+ * @file Tests for UIClient composable
+ * @description Unit tests for WebSocket client singleton, connection lifecycle,
+ *   request/response handling, and all simulator/station operations.
+ */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 import { UIClient } from '@/composables/UIClient'
 import {
+  AuthenticationType,
   OCPP20TransactionEventEnumType,
   OCPPVersion,
-  Protocol,
-  ProtocolVersion,
+  ProcedureName,
   ResponseStatus,
 } from '@/types'
 
-vi.mock('vue-toast-notification', () => ({
-  useToast: () => ({
-    error: vi.fn(),
-    info: vi.fn(),
-    success: vi.fn(),
-  }),
-}))
-
-class MockWebSocket {
-  addEventListener = vi.fn()
-  close = vi.fn()
-  onclose: (() => void) | null = null
-  onerror: ((event: Event) => void) | null = null
-  onmessage: ((event: MessageEvent) => void) | null = null
-
-  onopen: (() => void) | null = null
-
-  readyState = WebSocket.OPEN
-  removeEventListener = vi.fn()
-  send = vi.fn()
-  constructor () {
-    setTimeout(() => {
-      this.onopen?.()
-    }, 0)
-  }
-}
-
-const mockConfig = {
-  host: 'localhost',
-  port: 8080,
-  protocol: Protocol.UI,
-  version: ProtocolVersion['0.0.1'],
-}
+import { toastMock } from '../setup'
+import { createUIServerConfig, TEST_HASH_ID, TEST_ID_TAG } from './constants'
+import { MockWebSocket } from './helpers'
+
+// Reset singleton between tests
+beforeEach(() => {
+  // @ts-expect-error — accessing private static property for testing
+  UIClient.instance = null
+  vi.stubGlobal('WebSocket', MockWebSocket)
+})
+
+afterEach(() => {
+  vi.unstubAllGlobals()
+  // @ts-expect-error — accessing private static property for testing
+  UIClient.instance = null
+})
 
 describe('UIClient', () => {
+  describe('singleton pattern', () => {
+    it('should create instance when config is provided', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      expect(client).toBeInstanceOf(UIClient)
+    })
+
+    it('should throw when no config and no existing instance', () => {
+      expect(() => UIClient.getInstance()).toThrow(
+        'Cannot initialize UIClient if no configuration is provided'
+      )
+    })
+
+    it('should return same instance on subsequent calls without config', () => {
+      const first = UIClient.getInstance(createUIServerConfig())
+      const second = UIClient.getInstance()
+      expect(second).toBe(first)
+    })
+  })
+
   describe('isOCPP20x', () => {
     it('should return true for VERSION_20', () => {
       expect(UIClient.isOCPP20x(OCPPVersion.VERSION_20)).toBe(true)
@@ -62,109 +69,475 @@ describe('UIClient', () => {
     })
   })
 
-  describe('version-aware transaction methods', () => {
+  describe('WebSocket connection', () => {
+    it('should connect with ws:// URL format', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      expect(ws.url).toBe('ws://localhost:8080')
+    })
+
+    it('should connect with wss:// when secure is true', () => {
+      const client = UIClient.getInstance(createUIServerConfig({ secure: true }))
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      expect(ws.url).toBe('wss://localhost:8080')
+    })
+
+    it('should use protocol version as subprotocol without auth', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      expect(ws.protocols).toBe('ui0.0.1')
+    })
+
+    it('should include basic auth credentials in subprotocol', () => {
+      const config = createUIServerConfig({
+        authentication: {
+          enabled: true,
+          password: 'pass',
+          type: AuthenticationType.PROTOCOL_BASIC_AUTH,
+          username: 'user',
+        },
+      })
+      const client = UIClient.getInstance(config)
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      expect(ws.protocols).toBeInstanceOf(Array)
+      const protocols = ws.protocols as string[]
+      expect(protocols[0]).toBe('ui0.0.1')
+      expect(protocols[1]).toMatch(/^authorization\.basic\./)
+    })
+
+    it('should show success toast on WebSocket open', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateOpen()
+      expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('successfully opened'))
+    })
+
+    it('should log error on WebSocket error', () => {
+      const consoleSpy = vi.spyOn(console, 'error')
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateError()
+
+      expect(consoleSpy).toHaveBeenCalledWith(
+        expect.stringContaining('Error in WebSocket'),
+        expect.any(Event)
+      )
+    })
+
+    it('should handle WebSocket close event', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateClose()
+    })
+  })
+
+  describe('request/response handling', () => {
+    it('should resolve promise on SUCCESS response', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateOpen()
+
+      const promise = client.listChargingStations()
+      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      ws.simulateMessage([uuid, { status: ResponseStatus.SUCCESS }])
+
+      const result = await promise
+      expect(result.status).toBe(ResponseStatus.SUCCESS)
+    })
+
+    it('should reject promise on FAILURE response', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateOpen()
+
+      const promise = client.listChargingStations()
+      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      ws.simulateMessage([uuid, { status: ResponseStatus.FAILURE }])
+
+      await expect(promise).rejects.toEqual(
+        expect.objectContaining({ status: ResponseStatus.FAILURE })
+      )
+    })
+
+    it('should reject with Error on unknown response status', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateOpen()
+
+      const promise = client.listChargingStations()
+      const [uuid] = JSON.parse(ws.sentMessages[0]) as [string]
+      ws.simulateMessage([uuid, { status: 'unknown' }])
+
+      await expect(promise).rejects.toThrow(/not supported/)
+    })
+
+    it('should reject when WebSocket is not open', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.readyState = WebSocket.CLOSED
+
+      await expect(client.listChargingStations()).rejects.toThrow('connection closed')
+    })
+
+    it('should reject when ws.send throws', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateOpen()
+      ws.send.mockImplementation(() => {
+        throw new Error('send failed')
+      })
+
+      await expect(client.startSimulator()).rejects.toThrow('error Error: send failed')
+    })
+
+    it('should handle invalid JSON response gracefully', () => {
+      const consoleSpy = vi.spyOn(console, 'error')
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+
+      ws.onmessage?.({ data: 'not json' } as MessageEvent<string>)
+
+      expect(consoleSpy).toHaveBeenCalledWith(
+        'Invalid response JSON format',
+        expect.any(SyntaxError)
+      )
+    })
+
+    it('should handle non-array response gracefully', () => {
+      const consoleSpy = vi.spyOn(console, 'error')
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+
+      ws.simulateMessage({ notAnArray: true })
+
+      expect(consoleSpy).toHaveBeenCalledWith(
+        'Response not an array:',
+        expect.objectContaining({ notAnArray: true })
+      )
+    })
+
+    it('should throw on response with unknown UUID', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+
+      const fakeUUID = crypto.randomUUID()
+      expect(() => {
+        ws.simulateMessage([fakeUUID, { status: ResponseStatus.SUCCESS }])
+      }).toThrow('Not a response to a request')
+    })
+
+    it('should handle response with invalid UUID', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      ws.simulateMessage(['not-a-valid-uuid', { status: ResponseStatus.SUCCESS }])
+      // Should not throw — just logs error via toast
+    })
+  })
+
+  describe('simulator operations', () => {
     let client: UIClient
     let sendRequestSpy: ReturnType<typeof vi.spyOn>
 
     beforeEach(() => {
-      // @ts-expect-error - accessing private static property for testing
-      UIClient.instance = null
-      vi.stubGlobal('WebSocket', MockWebSocket)
-      client = UIClient.getInstance(mockConfig)
-      // @ts-expect-error - accessing private method for testing
+      client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private method for testing
       sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
         status: ResponseStatus.SUCCESS,
       })
     })
 
-    afterEach(() => {
-      vi.clearAllMocks()
-      vi.unstubAllGlobals()
-      // @ts-expect-error - accessing private static property for testing
-      UIClient.instance = null
+    it('should send START_SIMULATOR', async () => {
+      await client.startSimulator()
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_SIMULATOR, {})
+    })
+
+    it('should send STOP_SIMULATOR', async () => {
+      await client.stopSimulator()
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_SIMULATOR, {})
+    })
+
+    it('should send SIMULATOR_STATE', async () => {
+      await client.simulatorState()
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.SIMULATOR_STATE, {})
+    })
+  })
+
+  describe('charging station operations', () => {
+    let client: UIClient
+    let sendRequestSpy: ReturnType<typeof vi.spyOn>
+
+    beforeEach(() => {
+      client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private method for testing
+      sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
+    })
+
+    it('should send LIST_CHARGING_STATIONS', async () => {
+      await client.listChargingStations()
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.LIST_CHARGING_STATIONS, {})
+    })
+
+    it('should send LIST_TEMPLATES', async () => {
+      await client.listTemplates()
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.LIST_TEMPLATES, {})
+    })
+
+    it('should send START_CHARGING_STATION with hashIds', async () => {
+      await client.startChargingStation(TEST_HASH_ID)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_CHARGING_STATION, {
+        hashIds: [TEST_HASH_ID],
+      })
+    })
+
+    it('should send STOP_CHARGING_STATION with hashIds', async () => {
+      await client.stopChargingStation(TEST_HASH_ID)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_CHARGING_STATION, {
+        hashIds: [TEST_HASH_ID],
+      })
+    })
+
+    it('should send OPEN_CONNECTION with hashIds', async () => {
+      await client.openConnection(TEST_HASH_ID)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.OPEN_CONNECTION, {
+        hashIds: [TEST_HASH_ID],
+      })
+    })
+
+    it('should send CLOSE_CONNECTION with hashIds', async () => {
+      await client.closeConnection(TEST_HASH_ID)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.CLOSE_CONNECTION, {
+        hashIds: [TEST_HASH_ID],
+      })
+    })
+
+    it('should send DELETE_CHARGING_STATIONS with hashIds', async () => {
+      await client.deleteChargingStation(TEST_HASH_ID)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.DELETE_CHARGING_STATIONS, {
+        hashIds: [TEST_HASH_ID],
+      })
+    })
+
+    it('should send SET_SUPERVISION_URL with hashIds and url', async () => {
+      const url = 'ws://new-supervision:9001'
+      await client.setSupervisionUrl(TEST_HASH_ID, url)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.SET_SUPERVISION_URL, {
+        hashIds: [TEST_HASH_ID],
+        url,
+      })
+    })
+
+    it('should send AUTHORIZE with hashIds and idTag', async () => {
+      await client.authorize(TEST_HASH_ID, TEST_ID_TAG)
+      expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.AUTHORIZE, {
+        hashIds: [TEST_HASH_ID],
+        idTag: TEST_ID_TAG,
+      })
+    })
+  })
+
+  describe('ATG operations', () => {
+    let client: UIClient
+    let sendRequestSpy: ReturnType<typeof vi.spyOn>
+
+    beforeEach(() => {
+      client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private method for testing
+      sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
+    })
+
+    it('should send START_AUTOMATIC_TRANSACTION_GENERATOR', async () => {
+      await client.startAutomaticTransactionGenerator(TEST_HASH_ID, 1)
+      expect(sendRequestSpy).toHaveBeenCalledWith(
+        ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR,
+        { connectorIds: [1], hashIds: [TEST_HASH_ID] }
+      )
+    })
+
+    it('should send STOP_AUTOMATIC_TRANSACTION_GENERATOR', async () => {
+      await client.stopAutomaticTransactionGenerator(TEST_HASH_ID, 2)
+      expect(sendRequestSpy).toHaveBeenCalledWith(
+        ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR,
+        { connectorIds: [2], hashIds: [TEST_HASH_ID] }
+      )
+    })
+  })
+
+  describe('addChargingStations', () => {
+    it('should send ADD_CHARGING_STATIONS with template, count, and options', async () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private method for testing
+      const spy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
+      const options = { autoStart: true }
+
+      await client.addChargingStations('template.json', 3, options)
+
+      expect(spy).toHaveBeenCalledWith(ProcedureName.ADD_CHARGING_STATIONS, {
+        numberOfStations: 3,
+        options,
+        template: 'template.json',
+      })
+    })
+  })
+
+  describe('event listener management', () => {
+    it('should register WebSocket event listener', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      const listener = vi.fn()
+
+      client.registerWSEventListener('message', listener)
+
+      expect(ws.addEventListener).toHaveBeenCalledWith('message', listener, undefined)
+    })
+
+    it('should unregister WebSocket event listener', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const ws = client.ws as MockWebSocket
+      const listener = vi.fn()
+
+      client.unregisterWSEventListener('message', listener)
+
+      expect(ws.removeEventListener).toHaveBeenCalledWith('message', listener, undefined)
+    })
+  })
+
+  describe('setConfiguration', () => {
+    it('should close existing WebSocket and open new connection', () => {
+      const client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private property for testing
+      const oldWs = client.ws as MockWebSocket
+      oldWs.simulateOpen()
+
+      client.setConfiguration(createUIServerConfig({ port: 9090 }))
+
+      expect(oldWs.close).toHaveBeenCalled()
+      // @ts-expect-error — accessing private property for testing
+      const newWs = client.ws as MockWebSocket
+      expect(newWs).not.toBe(oldWs)
+      expect(newWs.url).toBe('ws://localhost:9090')
+    })
+  })
+
+  describe('version-aware transaction methods', () => {
+    let client: UIClient
+    let sendRequestSpy: ReturnType<typeof vi.spyOn>
+
+    beforeEach(() => {
+      client = UIClient.getInstance(createUIServerConfig())
+      // @ts-expect-error — accessing private method for testing
+      sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({
+        status: ResponseStatus.SUCCESS,
+      })
     })
 
     describe('startTransaction', () => {
       it('should send START_TRANSACTION for OCPP 1.6', async () => {
-        await client.startTransaction('hash123', {
+        await client.startTransaction(TEST_HASH_ID, {
           connectorId: 1,
-          idTag: 'idTag123',
+          idTag: TEST_ID_TAG,
           ocppVersion: OCPPVersion.VERSION_16,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_TRANSACTION, {
           connectorId: 1,
-          hashIds: ['hash123'],
-          idTag: 'idTag123',
+          hashIds: [TEST_HASH_ID],
+          idTag: TEST_ID_TAG,
         })
       })
 
       it('should send TRANSACTION_EVENT with evse object for OCPP 2.0.x', async () => {
-        await client.startTransaction('hash123', {
+        await client.startTransaction(TEST_HASH_ID, {
           connectorId: 2,
           evseId: 1,
-          idTag: 'idTag123',
+          idTag: TEST_ID_TAG,
           ocppVersion: OCPPVersion.VERSION_20,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.STARTED,
           evse: { connectorId: 2, id: 1 },
-          hashIds: ['hash123'],
-          idToken: { idToken: 'idTag123', type: 'ISO14443' },
+          hashIds: [TEST_HASH_ID],
+          idToken: { idToken: TEST_ID_TAG, type: 'ISO14443' },
         })
       })
 
       it('should default to OCPP 1.6 when version is undefined', async () => {
-        await client.startTransaction('hash123', { connectorId: 1, idTag: 'idTag123' })
+        await client.startTransaction(TEST_HASH_ID, {
+          connectorId: 1,
+          idTag: TEST_ID_TAG,
+        })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_TRANSACTION, {
           connectorId: 1,
-          hashIds: ['hash123'],
-          idTag: 'idTag123',
+          hashIds: [TEST_HASH_ID],
+          idTag: TEST_ID_TAG,
         })
       })
 
       it('should send undefined evse when evseId is not provided for OCPP 2.0.x', async () => {
-        await client.startTransaction('hash123', {
+        await client.startTransaction(TEST_HASH_ID, {
           connectorId: 1,
-          idTag: 'idTag123',
+          idTag: TEST_ID_TAG,
           ocppVersion: OCPPVersion.VERSION_20,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.STARTED,
           evse: undefined,
-          hashIds: ['hash123'],
-          idToken: { idToken: 'idTag123', type: 'ISO14443' },
+          hashIds: [TEST_HASH_ID],
+          idToken: { idToken: TEST_ID_TAG, type: 'ISO14443' },
         })
       })
 
       it('should send undefined idToken when idTag is not provided for OCPP 2.0.x', async () => {
-        await client.startTransaction('hash123', {
+        await client.startTransaction(TEST_HASH_ID, {
           connectorId: 1,
           evseId: 1,
           ocppVersion: OCPPVersion.VERSION_20,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.STARTED,
           evse: { connectorId: 1, id: 1 },
-          hashIds: ['hash123'],
+          hashIds: [TEST_HASH_ID],
           idToken: undefined,
         })
       })
 
       it('should send undefined evse and idToken when both absent for OCPP 2.0.x', async () => {
-        await client.startTransaction('hash123', {
+        await client.startTransaction(TEST_HASH_ID, {
           connectorId: 1,
           ocppVersion: OCPPVersion.VERSION_20,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.STARTED,
           evse: undefined,
-          hashIds: ['hash123'],
+          hashIds: [TEST_HASH_ID],
           idToken: undefined,
         })
       })
@@ -172,67 +545,67 @@ describe('UIClient', () => {
 
     describe('stopTransaction', () => {
       it('should send STOP_TRANSACTION for OCPP 1.6', async () => {
-        await client.stopTransaction('hash123', {
+        await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_16,
           transactionId: 12345,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
-          hashIds: ['hash123'],
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, {
+          hashIds: [TEST_HASH_ID],
           transactionId: 12345,
         })
       })
 
       it('should send TRANSACTION_EVENT with Ended for OCPP 2.0.x', async () => {
-        await client.stopTransaction('hash123', {
+        await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_20,
           transactionId: 'tx-uuid-123',
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.ENDED,
-          hashIds: ['hash123'],
+          hashIds: [TEST_HASH_ID],
           transactionId: 'tx-uuid-123',
         })
       })
 
       it('should default to OCPP 1.6 when version is undefined', async () => {
-        await client.stopTransaction('hash123', { transactionId: 12345 })
+        await client.stopTransaction(TEST_HASH_ID, { transactionId: 12345 })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
-          hashIds: ['hash123'],
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, {
+          hashIds: [TEST_HASH_ID],
           transactionId: 12345,
         })
       })
 
       it('should send undefined transactionId for OCPP 2.0.x when not provided', async () => {
-        await client.stopTransaction('hash123', {
+        await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_20,
           transactionId: undefined,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.ENDED,
-          hashIds: ['hash123'],
+          hashIds: [TEST_HASH_ID],
           transactionId: undefined,
         })
       })
 
       it('should convert numeric transactionId to string for OCPP 2.0.x', async () => {
-        await client.stopTransaction('hash123', {
+        await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_20,
           transactionId: 12345,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', {
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, {
           eventType: OCPP20TransactionEventEnumType.ENDED,
-          hashIds: ['hash123'],
+          hashIds: [TEST_HASH_ID],
           transactionId: '12345',
         })
       })
 
       it('should return failure for string transactionId with OCPP 1.6', async () => {
-        const result = await client.stopTransaction('hash123', {
+        const result = await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_16,
           transactionId: 'string-id',
         })
@@ -242,13 +615,13 @@ describe('UIClient', () => {
       })
 
       it('should send undefined transactionId for OCPP 1.6 when not provided', async () => {
-        await client.stopTransaction('hash123', {
+        await client.stopTransaction(TEST_HASH_ID, {
           ocppVersion: OCPPVersion.VERSION_16,
           transactionId: undefined,
         })
 
-        expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', {
-          hashIds: ['hash123'],
+        expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, {
+          hashIds: [TEST_HASH_ID],
           transactionId: undefined,
         })
       })
diff --git a/ui/web/tests/unit/Utils.test.ts b/ui/web/tests/unit/Utils.test.ts
new file mode 100644 (file)
index 0000000..d606d1c
--- /dev/null
@@ -0,0 +1,322 @@
+/**
+ * @file Tests for Utils composable
+ * @description Unit tests for type conversion, localStorage, UUID, and toggle state utilities.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+
+import {
+  convertToBoolean,
+  convertToInt,
+  deleteFromLocalStorage,
+  getFromLocalStorage,
+  getLocalStorage,
+  randomUUID,
+  resetToggleButtonState,
+  setToLocalStorage,
+  validateUUID,
+} from '@/composables/Utils'
+
+describe('Utils', () => {
+  describe('convertToBoolean', () => {
+    it('should return true for boolean true', () => {
+      expect(convertToBoolean(true)).toBe(true)
+    })
+
+    it('should return false for boolean false', () => {
+      expect(convertToBoolean(false)).toBe(false)
+    })
+
+    it('should return true for string "true"', () => {
+      expect(convertToBoolean('true')).toBe(true)
+    })
+
+    it('should return true for string "True" (case-insensitive)', () => {
+      expect(convertToBoolean('True')).toBe(true)
+    })
+
+    it('should return true for string "1"', () => {
+      expect(convertToBoolean('1')).toBe(true)
+    })
+
+    it('should return true for numeric 1', () => {
+      expect(convertToBoolean(1)).toBe(true)
+    })
+
+    it('should return false for string "false"', () => {
+      expect(convertToBoolean('false')).toBe(false)
+    })
+
+    it('should return false for string "0"', () => {
+      expect(convertToBoolean('0')).toBe(false)
+    })
+
+    it('should return false for numeric 0', () => {
+      expect(convertToBoolean(0)).toBe(false)
+    })
+
+    it('should return false for null', () => {
+      expect(convertToBoolean(null)).toBe(false)
+    })
+
+    it('should return false for undefined', () => {
+      expect(convertToBoolean(undefined)).toBe(false)
+    })
+
+    it('should return false for empty string', () => {
+      expect(convertToBoolean('')).toBe(false)
+    })
+
+    it('should return false for arbitrary string', () => {
+      expect(convertToBoolean('random')).toBe(false)
+    })
+
+    it('should return false for numeric 2', () => {
+      expect(convertToBoolean(2)).toBe(false)
+    })
+  })
+
+  describe('convertToInt', () => {
+    it('should return integer for integer input', () => {
+      expect(convertToInt(42)).toBe(42)
+    })
+
+    it('should truncate float to integer', () => {
+      expect(convertToInt(42.7)).toBe(42)
+    })
+
+    it('should truncate negative float to integer', () => {
+      expect(convertToInt(-42.7)).toBe(-42)
+    })
+
+    it('should parse string integer', () => {
+      expect(convertToInt('42')).toBe(42)
+    })
+
+    it('should parse negative string integer', () => {
+      expect(convertToInt('-42')).toBe(-42)
+    })
+
+    it('should return 0 for null', () => {
+      expect(convertToInt(null)).toBe(0)
+    })
+
+    it('should return 0 for undefined', () => {
+      expect(convertToInt(undefined)).toBe(0)
+    })
+
+    it('should throw error for non-numeric string', () => {
+      expect(() => convertToInt('abc')).toThrow(Error)
+      expect(() => convertToInt('abc')).toThrow("Cannot convert to integer: 'abc'")
+    })
+
+    it('should throw error for empty string', () => {
+      expect(() => convertToInt('')).toThrow(Error)
+    })
+
+    it('should return NaN for NaN input', () => {
+      const result = convertToInt(Number.NaN)
+      expect(Number.isNaN(result)).toBe(true)
+    })
+  })
+
+  describe('localStorage utilities', () => {
+    afterEach(() => {
+      localStorage.clear()
+    })
+
+    it('should get value from localStorage when key exists', () => {
+      // Arrange
+      const key = 'test-key'
+      const value = { count: 42, name: 'test' }
+      localStorage.setItem(key, JSON.stringify(value))
+
+      // Act
+      const result = getFromLocalStorage(key, null)
+
+      // Assert
+      expect(result).toEqual(value)
+    })
+
+    it('should return default value when key does not exist', () => {
+      // Arrange
+      const key = 'non-existent-key'
+      const defaultValue = { name: 'default' }
+
+      // Act
+      const result = getFromLocalStorage(key, defaultValue)
+
+      // Assert
+      expect(result).toEqual(defaultValue)
+    })
+
+    it('should set value to localStorage', () => {
+      // Arrange
+      const key = 'test-key'
+      const value = { count: 42, name: 'test' }
+
+      // Act
+      setToLocalStorage(key, value)
+
+      // Assert
+      expect(localStorage.getItem(key)).toBe(JSON.stringify(value))
+    })
+
+    it('should set string value to localStorage', () => {
+      // Arrange
+      const key = 'string-key'
+      const value = 'test-string'
+
+      // Act
+      setToLocalStorage(key, value)
+
+      // Assert
+      expect(localStorage.getItem(key)).toBe(JSON.stringify(value))
+    })
+
+    it('should delete value from localStorage', () => {
+      // Arrange
+      const key = 'test-key'
+      localStorage.setItem(key, 'test-value')
+
+      // Act
+      deleteFromLocalStorage(key)
+
+      // Assert
+      expect(localStorage.getItem(key)).toBeNull()
+    })
+
+    it('should return localStorage instance', () => {
+      // Act
+      const result = getLocalStorage()
+
+      // Assert
+      expect(result).toBe(localStorage)
+    })
+  })
+
+  describe('UUID', () => {
+    it('should generate valid UUID v4', () => {
+      // Act
+      const uuid = randomUUID()
+
+      // Assert
+      expect(validateUUID(uuid)).toBe(true)
+    })
+
+    it('should generate different UUIDs on each call', () => {
+      // Act
+      const uuid1 = randomUUID()
+      const uuid2 = randomUUID()
+
+      // Assert
+      expect(uuid1).not.toBe(uuid2)
+    })
+
+    it('should validate correct UUID v4 format', () => {
+      // Arrange
+      const validUUID = '550e8400-e29b-41d4-a716-446655440000'
+
+      // Act
+      const result = validateUUID(validUUID)
+
+      // Assert
+      expect(result).toBe(true)
+    })
+
+    it('should reject non-string UUID', () => {
+      // Act
+      const result = validateUUID(123)
+
+      // Assert
+      expect(result).toBe(false)
+    })
+
+    it('should reject invalid UUID format', () => {
+      // Act
+      const result = validateUUID('not-a-uuid')
+
+      // Assert
+      expect(result).toBe(false)
+    })
+
+    it('should reject UUID with wrong version (v3 instead of v4)', () => {
+      // Arrange
+      const v3UUID = '550e8400-e29b-31d4-a716-446655440000'
+
+      // Act
+      const result = validateUUID(v3UUID)
+
+      // Assert
+      expect(result).toBe(false)
+    })
+
+    it('should reject UUID with invalid variant', () => {
+      // Arrange
+      const invalidVariantUUID = '550e8400-e29b-41d4-c716-446655440000'
+
+      // Act
+      const result = validateUUID(invalidVariantUUID)
+
+      // Assert
+      expect(result).toBe(false)
+    })
+  })
+
+  describe('resetToggleButtonState', () => {
+    afterEach(() => {
+      localStorage.clear()
+    })
+
+    it('should delete non-shared toggle button state with correct key', () => {
+      // Arrange
+      const id = 'button-1'
+      const key = `toggle-button-${id}`
+      localStorage.setItem(key, 'true')
+
+      // Act
+      resetToggleButtonState(id, false)
+
+      // Assert
+      expect(localStorage.getItem(key)).toBeNull()
+    })
+
+    it('should delete shared toggle button state with correct key', () => {
+      // Arrange
+      const id = 'button-1'
+      const key = `shared-toggle-button-${id}`
+      localStorage.setItem(key, 'true')
+
+      // Act
+      resetToggleButtonState(id, true)
+
+      // Assert
+      expect(localStorage.getItem(key)).toBeNull()
+    })
+
+    it('should use non-shared key by default', () => {
+      // Arrange
+      const id = 'button-1'
+      const nonSharedKey = `toggle-button-${id}`
+      const sharedKey = `shared-toggle-button-${id}`
+      localStorage.setItem(nonSharedKey, 'true')
+      localStorage.setItem(sharedKey, 'true')
+
+      // Act
+      resetToggleButtonState(id)
+
+      // Assert
+      expect(localStorage.getItem(nonSharedKey)).toBeNull()
+      expect(localStorage.getItem(sharedKey)).toBe('true')
+    })
+
+    it('should handle deletion of non-existent key gracefully', () => {
+      // Arrange
+      const id = 'non-existent'
+
+      // Act & Assert
+      expect(() => {
+        resetToggleButtonState(id)
+      }).not.toThrow()
+    })
+  })
+})
diff --git a/ui/web/tests/unit/constants.ts b/ui/web/tests/unit/constants.ts
new file mode 100644 (file)
index 0000000..6286333
--- /dev/null
@@ -0,0 +1,117 @@
+/**
+ * @file Test data factories for Vue.js web UI unit tests
+ * @description Factory functions (NOT static objects) for ChargingStationData,
+ *   ConnectorStatus, and other test fixtures. Using factories prevents shared state.
+ */
+import {
+  type ChargingStationData,
+  type ChargingStationInfo,
+  type ConnectorStatus,
+  type EvseEntry,
+  OCPPVersion,
+  Protocol,
+  ProtocolVersion,
+  type UIServerConfigurationSection,
+} from '@/types'
+import {
+  OCPP16AvailabilityType,
+  OCPP16ChargePointStatus,
+  OCPP16RegistrationStatus,
+} from '@/types/ChargingStationType'
+
+// ── Shared Test Constants ─────────────────────────────────────────────────────
+
+export const TEST_HASH_ID = 'test-hash-id-abc123'
+export const TEST_ID_TAG = 'RFID-TAG-001'
+export const TEST_STATION_ID = 'CS-TEST-001'
+export const TEST_WS_URL = 'ws://localhost:8080'
+
+// ── Factory Functions ─────────────────────────────────────────────────────────
+
+/**
+ * Creates a ChargingStationData fixture with sensible defaults.
+ * @param overrides - Optional partial overrides for the fixture
+ * @returns ChargingStationData fixture
+ */
+export function createChargingStationData (
+  overrides?: Partial<ChargingStationData>
+): ChargingStationData {
+  return {
+    bootNotificationResponse: {
+      currentTime: new Date('2024-01-01T00:00:00Z'),
+      interval: 60,
+      status: OCPP16RegistrationStatus.ACCEPTED,
+    },
+    connectors: [{ connector: createConnectorStatus(), connectorId: 1 }],
+    ocppConfiguration: { configurationKey: [] },
+    started: true,
+    stationInfo: createStationInfo(),
+    supervisionUrl: 'ws://supervisor.example.com:9000',
+    wsState: WebSocket.OPEN,
+    ...overrides,
+  }
+}
+
+/**
+ * Creates a ConnectorStatus fixture with sensible defaults.
+ * @param overrides - Optional partial overrides for the fixture
+ * @returns ConnectorStatus fixture
+ */
+export function createConnectorStatus (overrides?: Partial<ConnectorStatus>): ConnectorStatus {
+  return {
+    availability: OCPP16AvailabilityType.OPERATIVE,
+    status: OCPP16ChargePointStatus.AVAILABLE,
+    ...overrides,
+  }
+}
+
+/**
+ * Creates an EvseEntry fixture with nested connector.
+ * @param overrides - Optional partial overrides for the fixture
+ * @returns EvseEntry fixture
+ */
+export function createEvseEntry (overrides?: Partial<EvseEntry>): EvseEntry {
+  return {
+    availability: OCPP16AvailabilityType.OPERATIVE,
+    connectors: [{ connector: createConnectorStatus(), connectorId: 1 }],
+    evseId: 1,
+    ...overrides,
+  }
+}
+
+/**
+ * Creates a ChargingStationInfo fixture with sensible defaults.
+ * @param overrides - Optional partial overrides for the fixture
+ * @returns ChargingStationInfo fixture
+ */
+export function createStationInfo (overrides?: Partial<ChargingStationInfo>): ChargingStationInfo {
+  return {
+    baseName: 'CS-TEST',
+    chargePointModel: 'TestModel',
+    chargePointVendor: 'TestVendor',
+    chargingStationId: TEST_STATION_ID,
+    firmwareVersion: '1.0.0',
+    hashId: TEST_HASH_ID,
+    ocppVersion: OCPPVersion.VERSION_16,
+    templateIndex: 0,
+    templateName: 'template-test.json',
+    ...overrides,
+  }
+}
+
+/**
+ * Creates a UIServerConfigurationSection fixture with sensible defaults.
+ * @param overrides - Optional partial overrides for the fixture
+ * @returns UIServerConfigurationSection fixture
+ */
+export function createUIServerConfig (
+  overrides?: Partial<UIServerConfigurationSection>
+): UIServerConfigurationSection {
+  return {
+    host: 'localhost',
+    port: 8080,
+    protocol: Protocol.UI,
+    version: ProtocolVersion['0.0.1'],
+    ...overrides,
+  }
+}
diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts
new file mode 100644 (file)
index 0000000..0d2be6d
--- /dev/null
@@ -0,0 +1,167 @@
+/**
+ * @file Shared test utilities for Vue.js web UI unit tests
+ * @description MockWebSocket, withSetup composable helper, mock factories.
+ */
+import { flushPromises } from '@vue/test-utils'
+import { vi } from 'vitest'
+import { type App, createApp } from 'vue'
+
+import { ResponseStatus } from '@/types'
+
+export { flushPromises as flushAllPromises }
+
+// ── MockUIClient ──────────────────────────────────────────────────────────────
+
+export interface MockUIClient {
+  addChargingStations: ReturnType<typeof vi.fn>
+  authorize: ReturnType<typeof vi.fn>
+  closeConnection: ReturnType<typeof vi.fn>
+  deleteChargingStation: ReturnType<typeof vi.fn>
+  listChargingStations: ReturnType<typeof vi.fn>
+  listTemplates: ReturnType<typeof vi.fn>
+  openConnection: ReturnType<typeof vi.fn>
+  registerWSEventListener: ReturnType<typeof vi.fn>
+  setConfiguration: ReturnType<typeof vi.fn>
+  setSupervisionUrl: ReturnType<typeof vi.fn>
+  simulatorState: ReturnType<typeof vi.fn>
+  startAutomaticTransactionGenerator: ReturnType<typeof vi.fn>
+  startChargingStation: ReturnType<typeof vi.fn>
+  startSimulator: ReturnType<typeof vi.fn>
+  startTransaction: ReturnType<typeof vi.fn>
+  stopAutomaticTransactionGenerator: ReturnType<typeof vi.fn>
+  stopChargingStation: ReturnType<typeof vi.fn>
+  stopSimulator: ReturnType<typeof vi.fn>
+  stopTransaction: ReturnType<typeof vi.fn>
+  unregisterWSEventListener: ReturnType<typeof vi.fn>
+}
+
+// ── ButtonStub ────────────────────────────────────────────────────────────────
+
+/** Functional Button stub that preserves click event propagation. */
+export const ButtonStub = {
+  emits: ['click'],
+  template: '<button @click="$emit(\'click\')"><slot /></button>',
+}
+
+// ── MockWebSocket ─────────────────────────────────────────────────────────────
+
+export class MockWebSocket {
+  static readonly CLOSED = 3
+  static readonly CLOSING = 2
+  static readonly CONNECTING = 0
+  static readonly OPEN = 1
+
+  addEventListener: ReturnType<typeof vi.fn>
+  close: ReturnType<typeof vi.fn>
+  readonly CLOSED = 3
+  readonly CLOSING = 2
+  readonly CONNECTING = 0
+  onclose: ((event: CloseEvent) => void) | null = null
+  onerror: ((event: Event) => void) | null = null
+  onmessage: ((event: MessageEvent) => void) | null = null
+  onopen: (() => void) | null = null
+  readonly OPEN = 1
+  readyState: number = MockWebSocket.CONNECTING
+  removeEventListener: ReturnType<typeof vi.fn>
+  send: ReturnType<typeof vi.fn>
+  sentMessages: string[] = []
+
+  constructor (
+    public readonly url = '',
+    public readonly protocols: string | string[] = []
+  ) {
+    this.addEventListener = vi.fn()
+    this.close = vi.fn()
+    this.removeEventListener = vi.fn()
+    // Intercept send to capture messages
+    this.send = vi.fn((data: string) => {
+      this.sentMessages.push(data)
+    })
+  }
+
+  simulateClose (code = 1000, reason = ''): void {
+    this.readyState = MockWebSocket.CLOSED
+    const event = { code, reason } as CloseEvent
+    this.onclose?.(event)
+  }
+
+  simulateError (): void {
+    const event = new Event('error')
+    this.onerror?.(event)
+  }
+
+  simulateMessage (data: unknown): void {
+    const event = { data: JSON.stringify(data) } as MessageEvent<string>
+    this.onmessage?.(event)
+  }
+
+  simulateOpen (): void {
+    this.readyState = MockWebSocket.OPEN
+    this.onopen?.()
+  }
+}
+
+// ── createMockUIClient ────────────────────────────────────────────────────────
+
+/**
+ * Creates a mock UIClient. Async methods return success responses; event listener and configuration methods are synchronous stubs.
+ * @returns MockUIClient with all methods mocked
+ */
+export function createMockUIClient (): MockUIClient {
+  const successResponse = { status: ResponseStatus.SUCCESS }
+  return {
+    addChargingStations: vi.fn().mockResolvedValue(successResponse),
+    authorize: vi.fn().mockResolvedValue(successResponse),
+    closeConnection: vi.fn().mockResolvedValue(successResponse),
+    deleteChargingStation: vi.fn().mockResolvedValue(successResponse),
+    listChargingStations: vi.fn().mockResolvedValue({ ...successResponse, chargingStations: [] }),
+    listTemplates: vi.fn().mockResolvedValue({ ...successResponse, templates: [] }),
+    openConnection: vi.fn().mockResolvedValue(successResponse),
+    registerWSEventListener: vi.fn(),
+    setConfiguration: vi.fn(),
+    setSupervisionUrl: vi.fn().mockResolvedValue(successResponse),
+    simulatorState: vi
+      .fn()
+      .mockResolvedValue({ ...successResponse, state: { started: false, templateStatistics: {} } }),
+    startAutomaticTransactionGenerator: vi.fn().mockResolvedValue(successResponse),
+    startChargingStation: vi.fn().mockResolvedValue(successResponse),
+    startSimulator: vi.fn().mockResolvedValue(successResponse),
+    startTransaction: vi.fn().mockResolvedValue(successResponse),
+    stopAutomaticTransactionGenerator: vi.fn().mockResolvedValue(successResponse),
+    stopChargingStation: vi.fn().mockResolvedValue(successResponse),
+    stopSimulator: vi.fn().mockResolvedValue(successResponse),
+    stopTransaction: vi.fn().mockResolvedValue(successResponse),
+    unregisterWSEventListener: vi.fn(),
+  }
+}
+
+// ── withSetup ─────────────────────────────────────────────────────────────────
+// Official Vue core team pattern for testing composables with lifecycle hooks
+
+/**
+ * Composable testing helper per Vue core team pattern. Creates a minimal app to run composable with lifecycle hooks.
+ * @param composable - The composable function to test
+ * @param options - Optional configuration with provide object
+ * @param options.provide - Optional provide object for dependency injection
+ * @returns Tuple of [composable result, app instance]
+ */
+export function withSetup<T> (
+  composable: () => T,
+  options?: { provide?: Record<string, unknown> }
+): [T, App] {
+  let result!: T
+  const app = createApp({
+    setup () {
+      result = composable()
+      // Suppress missing template warning
+      return () => null
+    },
+  })
+  if (options?.provide != null) {
+    for (const [key, value] of Object.entries(options.provide)) {
+      app.provide(key, value)
+    }
+  }
+  app.mount(document.createElement('div'))
+  return [result, app]
+}
index 891e3761ad2a39f6fb69d49df5fdabcf395e7de3..e94dcc54be3d21596678814f2ea4414ad28144fb 100644 (file)
@@ -4,17 +4,38 @@ import { configDefaults, defineConfig } from 'vitest/config'
 
 import viteConfig from './vite.config'
 
+const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10)
+
 export default mergeConfig(
   viteConfig,
   defineConfig({
     test: {
+      clearMocks: true,
       coverage: {
+        exclude: [
+          'src/types/**',
+          'src/main.ts',
+          'src/**/index.ts',
+          'src/shims-vue.d.ts',
+          'src/assets/**',
+          'src/router/index.ts',
+        ],
+        include: ['src/**/*.{ts,vue}'],
         provider: 'v8',
         reporter: ['text', 'lcov'],
+        thresholds: {
+          branches: 85,
+          functions: 80,
+          lines: 87,
+          statements: 87,
+        },
       },
       environment: 'jsdom',
       exclude: [...configDefaults.exclude, 'e2e/*'],
+      execArgv: nodeMajor >= 25 ? ['--no-webstorage'] : [],
+      restoreMocks: true,
       root: fileURLToPath(new URL('./', import.meta.url)),
+      setupFiles: ['./tests/setup.ts'],
     },
   })
 )