--- /dev/null
+/**
+ * @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()
+})
--- /dev/null
+/**
+ * @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 })
+ )
+ })
+})
--- /dev/null
+/**
+ * @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'
+ )
+ })
+ })
+})
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
-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)')
})
--- /dev/null
+/**
+ * @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)
+ })
+ })
+})
--- /dev/null
+/**
+ * @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()
+ })
+})
--- /dev/null
+/**
+ * @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')
+ })
+})
--- /dev/null
+/**
+ * @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()
+ })
+ })
+})
--- /dev/null
+/**
+ * @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')
+ })
+ })
+})
+/**
+ * @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)
})
})
- 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,
})
})
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',
})
})
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,
})
})
--- /dev/null
+/**
+ * @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()
+ })
+ })
+})
--- /dev/null
+/**
+ * @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,
+ }
+}
--- /dev/null
+/**
+ * @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]
+}
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'],
},
})
)