From: Jérôme Benoit Date: Thu, 19 Mar 2026 11:39:02 +0000 (+0100) Subject: test(webui): add comprehensive unit test suite (#1738) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=8e92434ca274469cc8b0643546a9980710dc6bc1;p=e-mobility-charging-stations-simulator.git test(webui): add comprehensive unit test suite (#1738) * test(webui): add test infrastructure and setup * test(webui): add Utils composable tests * test(webui): refactor and expand UIClient tests * test(webui): add action modal component tests * test(webui): fix JSDoc warnings in mount factory functions * test(webui): add ChargingStationsView tests * [autofix.ci] apply automated fixes * test(webui): finalize coverage and verify quality gates * test(webui): harmonize test infrastructure — extract ButtonStub, unify toast mock, DRY error tests * test(webui): add missing coverage for timeout, server switching, authorize errors, WS states * test(webui): raise coverage thresholds to match achieved 93%/91%/85%/93% * test(webui): address PR review — unify mock cleanup, improve MockWebSocket fidelity, fix types * test(webui): adapt tests for new OCPP Version column * test(webui): remove MockWebSocket auto-open, robustify component lookups, add open assertion * test(webui): init MockWebSocket readyState to CONNECTING per WebSocket spec * fix(webui): use window.localStorage for Node 22+ jsdom compatibility * fix(webui): disable Node 25+ native webstorage to prevent jsdom localStorage conflict * test(webui): await router.isReady() in App mount test --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- diff --git a/ui/web/tests/setup.ts b/ui/web/tests/setup.ts new file mode 100644 index 00000000..b1a7c710 --- /dev/null +++ b/ui/web/tests/setup.ts @@ -0,0 +1,31 @@ +/** + * @file Global test setup for Vue.js web UI unit tests + * @description Shared mocks, stubs, and cleanup for all test files. + * Conventions follow the TEST_STYLE_GUIDE.md naming patterns adapted + * to the Vitest + `@vue/test-utils` assertion API. + */ +import { config } from '@vue/test-utils' +import { afterEach, type Mock, vi } from 'vitest' + +// Global stubs: stub router components in all tests by default +config.global.stubs = { + RouterLink: true, + RouterView: true, +} + +// ── Shared toast mock ───────────────────────────────────────────────────────── +// Shared across all test files. Import `toastMock` from setup.ts to assert on calls. +export const toastMock: { error: Mock; info: Mock; success: Mock; warning: Mock } = { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), +} + +vi.mock('vue-toast-notification', () => ({ + useToast: () => toastMock, +})) + +afterEach(() => { + localStorage.clear() +}) diff --git a/ui/web/tests/unit/AddChargingStations.test.ts b/ui/web/tests/unit/AddChargingStations.test.ts new file mode 100644 index 00000000..4a7d0d20 --- /dev/null +++ b/ui/web/tests/unit/AddChargingStations.test.ts @@ -0,0 +1,114 @@ +/** + * @file Tests for AddChargingStations component + * @description Unit tests for add stations form — template selection, submission, and navigation. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import AddChargingStations from '@/components/actions/AddChargingStations.vue' + +import { toastMock } from '../setup' +import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers' + +describe('AddChargingStations', () => { + let mockClient: MockUIClient + let mockRouter: { push: ReturnType } + + /** + * Mounts AddChargingStations with mock UIClient, router, and templates. + * @returns Mounted component wrapper + */ + function mountComponent () { + mockClient = createMockUIClient() + mockRouter = { push: vi.fn() } + return mount(AddChargingStations, { + global: { + config: { + globalProperties: { + $router: mockRouter, + $templates: ref(['template-A.json', 'template-B.json']), + $toast: toastMock, + $uiClient: mockClient, + } as never, + }, + stubs: { + Button: ButtonStub, + }, + }, + }) + } + + it('should render template select dropdown', () => { + const wrapper = mountComponent() + expect(wrapper.find('select').exists()).toBe(true) + }) + + it('should render template options from $templates', () => { + const wrapper = mountComponent() + const options = wrapper.findAll('option') + expect(options.some(o => o.text().includes('template-A.json'))).toBe(true) + expect(options.some(o => o.text().includes('template-B.json'))).toBe(true) + }) + + it('should render number of stations input', () => { + const wrapper = mountComponent() + expect(wrapper.find('#number-of-stations').exists()).toBe(true) + }) + + it('should render supervision URL input', () => { + const wrapper = mountComponent() + expect(wrapper.find('#supervision-url').exists()).toBe(true) + }) + + it('should call addChargingStations on button click', async () => { + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.addChargingStations).toHaveBeenCalled() + }) + + it('should navigate to charging-stations on success', async () => { + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' }) + }) + + it('should show error toast on failure', async () => { + const wrapper = mountComponent() + mockClient.addChargingStations = vi.fn().mockRejectedValue(new Error('Network error')) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalled() + }) + + it('should render option checkboxes', () => { + const wrapper = mountComponent() + const checkboxes = wrapper.findAll('input[type="checkbox"]') + expect(checkboxes.length).toBe(4) + }) + + it('should pass supervision URL option when provided', async () => { + const wrapper = mountComponent() + await wrapper.find('#supervision-url').setValue('wss://custom-server.com') + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.addChargingStations).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ supervisionUrls: 'wss://custom-server.com' }) + ) + }) + + it('should not pass supervision URL option when empty', async () => { + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.addChargingStations).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ supervisionUrls: undefined }) + ) + }) +}) diff --git a/ui/web/tests/unit/CSConnector.test.ts b/ui/web/tests/unit/CSConnector.test.ts new file mode 100644 index 00000000..84ecb528 --- /dev/null +++ b/ui/web/tests/unit/CSConnector.test.ts @@ -0,0 +1,204 @@ +/** + * @file Tests for CSConnector component + * @description Unit tests for connector row display, transaction actions, and ATG controls. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { UIClient } from '@/composables/UIClient' + +import CSConnector from '@/components/charging-stations/CSConnector.vue' +import { useUIClient } from '@/composables' +import { OCPP16ChargePointStatus } from '@/types/ChargingStationType' + +import { toastMock } from '../setup' +import { createConnectorStatus, TEST_HASH_ID, TEST_STATION_ID } from './constants' +import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers' + +vi.mock('@/composables', async importOriginal => { + const actual = await importOriginal() + return { ...(actual as Record), 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 = {}) { + return mount(CSConnector, { + global: { + stubs: { + Button: ButtonStub, + ToggleButton: true, + }, + }, + props: { + chargingStationId: TEST_STATION_ID, + connector: createConnectorStatus(), + connectorId: 1, + hashId: TEST_HASH_ID, + ...overrideProps, + }, + }) +} + +describe('CSConnector', () => { + let mockClient: MockUIClient + + beforeEach(() => { + mockClient = createMockUIClient() + vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient) + }) + + describe('connector display', () => { + it('should display connector ID without EVSE prefix', () => { + const wrapper = mountCSConnector() + const cells = wrapper.findAll('td') + expect(cells[0].text()).toBe('1') + }) + + it('should display connector ID with EVSE prefix when evseId provided', () => { + const wrapper = mountCSConnector({ evseId: 2 }) + const cells = wrapper.findAll('td') + expect(cells[0].text()).toBe('2/1') + }) + + it('should display connector status', () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ status: OCPP16ChargePointStatus.CHARGING }), + }) + const cells = wrapper.findAll('td') + expect(cells[1].text()).toBe('Charging') + }) + + it('should display Ø when connector status is undefined', () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ status: undefined }), + }) + const cells = wrapper.findAll('td') + expect(cells[1].text()).toBe('Ø') + }) + + it('should display No when transaction not started', () => { + const wrapper = mountCSConnector() + const cells = wrapper.findAll('td') + expect(cells[2].text()).toBe('No') + }) + + it('should display Yes with transaction ID when transaction started', () => { + const wrapper = mountCSConnector({ + connector: createConnectorStatus({ transactionId: 12345, transactionStarted: true }), + }) + const cells = wrapper.findAll('td') + expect(cells[2].text()).toBe('Yes (12345)') + }) + + it('should display ATG started as Yes when active', () => { + const wrapper = mountCSConnector({ atgStatus: { start: true } }) + const cells = wrapper.findAll('td') + expect(cells[3].text()).toBe('Yes') + }) + + it('should display ATG started as No when not active', () => { + const wrapper = mountCSConnector({ atgStatus: { start: false } }) + const cells = wrapper.findAll('td') + expect(cells[3].text()).toBe('No') + }) + + it('should display ATG started as No when atgStatus undefined', () => { + const wrapper = mountCSConnector() + const cells = wrapper.findAll('td') + expect(cells[3].text()).toBe('No') + }) + }) + + describe('transaction actions', () => { + it('should call stopTransaction with correct params', async () => { + const connector = createConnectorStatus({ transactionId: 12345, transactionStarted: true }) + const wrapper = mountCSConnector({ connector }) + const buttons = wrapper.findAll('button') + const stopBtn = buttons.find(b => b.text() === 'Stop Transaction') + await stopBtn?.trigger('click') + await flushPromises() + expect(mockClient.stopTransaction).toHaveBeenCalledWith(TEST_HASH_ID, { + ocppVersion: undefined, + transactionId: 12345, + }) + }) + + it('should show error toast when no transaction to stop', async () => { + const connector = createConnectorStatus({ transactionId: undefined }) + const wrapper = mountCSConnector({ connector }) + const buttons = wrapper.findAll('button') + const stopBtn = buttons.find(b => b.text() === 'Stop Transaction') + await stopBtn?.trigger('click') + expect(toastMock.error).toHaveBeenCalledWith('No transaction to stop') + }) + + it('should show success toast after stopping transaction', async () => { + const connector = createConnectorStatus({ transactionId: 99, transactionStarted: true }) + const wrapper = mountCSConnector({ connector }) + const buttons = wrapper.findAll('button') + const stopBtn = buttons.find(b => b.text() === 'Stop Transaction') + await stopBtn?.trigger('click') + await flushPromises() + expect(toastMock.success).toHaveBeenCalledWith('Transaction successfully stopped') + }) + }) + + describe('ATG actions', () => { + it('should call startAutomaticTransactionGenerator', async () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const startAtgBtn = buttons.find(b => b.text() === 'Start ATG') + await startAtgBtn?.trigger('click') + await flushPromises() + expect(mockClient.startAutomaticTransactionGenerator).toHaveBeenCalledWith(TEST_HASH_ID, 1) + }) + + it('should call stopAutomaticTransactionGenerator', async () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const stopAtgBtn = buttons.find(b => b.text() === 'Stop ATG') + await stopAtgBtn?.trigger('click') + await flushPromises() + expect(mockClient.stopAutomaticTransactionGenerator).toHaveBeenCalledWith(TEST_HASH_ID, 1) + }) + + it('should show error toast on ATG start failure', async () => { + mockClient.startAutomaticTransactionGenerator.mockRejectedValueOnce(new Error('fail')) + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const startAtgBtn = buttons.find(b => b.text() === 'Start ATG') + await startAtgBtn?.trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith( + 'Error at starting automatic transaction generator' + ) + }) + + it('should show success toast when ATG started', async () => { + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const btn = buttons.find(b => b.text().includes('Start ATG')) + await btn?.trigger('click') + await flushPromises() + expect(toastMock.success).toHaveBeenCalledWith( + 'Automatic transaction generator successfully started' + ) + }) + + it('should show error toast when ATG stop fails', async () => { + mockClient.stopAutomaticTransactionGenerator.mockRejectedValueOnce(new Error('fail')) + const wrapper = mountCSConnector() + const buttons = wrapper.findAll('button') + const btn = buttons.find(b => b.text().includes('Stop ATG')) + await btn?.trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith( + 'Error at stopping automatic transaction generator' + ) + }) + }) +}) diff --git a/ui/web/tests/unit/CSData.test.ts b/ui/web/tests/unit/CSData.test.ts new file mode 100644 index 00000000..2d3f4643 --- /dev/null +++ b/ui/web/tests/unit/CSData.test.ts @@ -0,0 +1,299 @@ +/** + * @file Tests for CSData component + * @description Unit tests for charging station row display, actions, and connector entry generation. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { UIClient } from '@/composables/UIClient' +import type { ChargingStationData } from '@/types' + +import CSConnector from '@/components/charging-stations/CSConnector.vue' +import CSData from '@/components/charging-stations/CSData.vue' +import { useUIClient } from '@/composables' +import { OCPPVersion } from '@/types' + +import { toastMock } from '../setup' +import { + createChargingStationData, + createConnectorStatus, + createEvseEntry, + createStationInfo, +} from './constants' +import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers' + +vi.mock('@/composables', async importOriginal => { + const actual = await importOriginal() + return { ...(actual as Record), 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) + }) + }) +}) diff --git a/ui/web/tests/unit/CSTable.test.ts b/ui/web/tests/unit/CSTable.test.ts index ba8c8c25..f215e1e7 100644 --- a/ui/web/tests/unit/CSTable.test.ts +++ b/ui/web/tests/unit/CSTable.test.ts @@ -1,24 +1,84 @@ -import { shallowMount } from '@vue/test-utils' -import { expect, test } from 'vitest' +/** + * @file Tests for CSTable component + * @description Unit tests for charging station table column headers and row rendering. + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' import type { ChargingStationData } from '@/types' import CSTable from '@/components/charging-stations/CSTable.vue' -test('renders CS table columns name', () => { - const chargingStations: ChargingStationData[] = [] - const wrapper = shallowMount(CSTable, { - props: { chargingStations, idTag: '0' }, +import { createChargingStationData, createStationInfo } from './constants' + +/** + * Mounts CSTable with CSData stubbed out. + * @param chargingStations - Array of charging stations + * @returns Mounted component wrapper + */ +function mountCSTable (chargingStations: ChargingStationData[] = []) { + return mount(CSTable, { + global: { stubs: { CSData: true } }, + props: { chargingStations }, + }) +} + +describe('CSTable', () => { + describe('column headers', () => { + it('should render all column headers', () => { + const wrapper = mountCSTable() + const text = wrapper.text() + expect(text).toContain('Name') + expect(text).toContain('Started') + expect(text).toContain('Supervision Url') + expect(text).toContain('WebSocket State') + expect(text).toContain('Registration Status') + expect(text).toContain('OCPP Version') + expect(text).toContain('Template') + expect(text).toContain('Vendor') + expect(text).toContain('Model') + expect(text).toContain('Firmware') + expect(text).toContain('Actions') + expect(text).toContain('Connector(s)') + }) + + it('should render table caption', () => { + const wrapper = mountCSTable() + expect(wrapper.text()).toContain('Charging Stations') + }) + }) + + describe('row rendering', () => { + it('should render a CSData row for each charging station', () => { + const stations = [ + createChargingStationData(), + createChargingStationData({ + stationInfo: createStationInfo({ chargingStationId: 'CS-002', hashId: 'hash-2' }), + }), + ] + const wrapper = mountCSTable(stations) + expect(wrapper.findAllComponents({ name: 'CSData' })).toHaveLength(2) + }) + + it('should handle empty charging stations array', () => { + const wrapper = mountCSTable([]) + expect(wrapper.findAllComponents({ name: 'CSData' })).toHaveLength(0) + }) + + it('should propagate need-refresh event from CSData', async () => { + const stations = [createChargingStationData()] + const CSDataStub = { + emits: ['need-refresh'], + template: '', + } + const wrapper = mount(CSTable, { + global: { stubs: { CSData: CSDataStub } }, + props: { chargingStations: stations }, + }) + const csDataComponent = wrapper.findComponent(CSDataStub) + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + await csDataComponent.vm.$emit('need-refresh') + expect(wrapper.emitted('need-refresh')).toHaveLength(1) + }) }) - expect(wrapper.text()).to.include('Name') - expect(wrapper.text()).to.include('Started') - expect(wrapper.text()).to.include('Supervision Url') - expect(wrapper.text()).to.include('WebSocket State') - expect(wrapper.text()).to.include('Registration Status') - expect(wrapper.text()).to.include('Template') - expect(wrapper.text()).to.include('Vendor') - expect(wrapper.text()).to.include('Model') - expect(wrapper.text()).to.include('Firmware') - expect(wrapper.text()).to.include('Actions') - expect(wrapper.text()).to.include('Connector(s)') }) diff --git a/ui/web/tests/unit/ChargingStationsView.test.ts b/ui/web/tests/unit/ChargingStationsView.test.ts new file mode 100644 index 00000000..b57c32fc --- /dev/null +++ b/ui/web/tests/unit/ChargingStationsView.test.ts @@ -0,0 +1,417 @@ +/** + * @file Tests for ChargingStationsView component + * @description Unit tests for the main view: WS event listeners, data fetching, + * simulator state display, CSTable visibility, UI server selector, and error handling. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +import type { UIClient } from '@/composables/UIClient' + +import { useUIClient } from '@/composables' +import { ResponseStatus } from '@/types' +import ChargingStationsView from '@/views/ChargingStationsView.vue' + +import { toastMock } from '../setup' +import { createChargingStationData, createUIServerConfig } from './constants' +import { createMockUIClient, type MockUIClient } from './helpers' + +vi.mock('@/composables', async importOriginal => { + const actual = await importOriginal() + return { ...(actual as Record), 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[] + 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: '
' }, + CSTable: true, + ReloadButton: { + emits: ['click'], + name: 'ReloadButton', + props: ['loading'], + template: '', + }, + }, + }, + }) +} + +/** + * Triggers the 'open' WS event handler to simulate a connection open (calls getData). + */ +async function triggerWSOpen (): Promise { + const openHandler = getWSHandler('open') + openHandler?.() + await flushPromises() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('ChargingStationsView', () => { + beforeEach(() => { + mockClient = createMockUIClient() + vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('WebSocket event listeners', () => { + it('should register open, error, close listeners on mount', () => { + mountView() + expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('open', expect.any(Function)) + expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('error', expect.any(Function)) + expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('close', expect.any(Function)) + }) + + it('should register exactly 3 listeners', () => { + mountView() + expect(mockClient.registerWSEventListener).toHaveBeenCalledTimes(3) + }) + + it('should unregister open, error, close listeners on unmount', () => { + const wrapper = mountView() + wrapper.unmount() + expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith( + 'open', + expect.any(Function) + ) + expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith( + 'error', + expect.any(Function) + ) + expect(mockClient.unregisterWSEventListener).toHaveBeenCalledWith( + 'close', + expect.any(Function) + ) + }) + + it('should unregister exactly 3 listeners on unmount', () => { + const wrapper = mountView() + wrapper.unmount() + expect(mockClient.unregisterWSEventListener).toHaveBeenCalledTimes(3) + }) + }) + + describe('getData on WS open', () => { + it('should call simulatorState when WS opens', async () => { + mountView() + await triggerWSOpen() + expect(mockClient.simulatorState).toHaveBeenCalled() + }) + + it('should call listTemplates when WS opens', async () => { + mountView() + await triggerWSOpen() + expect(mockClient.listTemplates).toHaveBeenCalled() + }) + + it('should call listChargingStations when WS opens', async () => { + mountView() + await triggerWSOpen() + expect(mockClient.listChargingStations).toHaveBeenCalled() + }) + }) + + describe('simulator state display', () => { + it('should show "Start Simulator" when simulator not started', async () => { + mockClient.simulatorState = vi.fn().mockResolvedValue({ + state: { started: false, templateStatistics: {} }, + status: ResponseStatus.SUCCESS, + }) + const wrapper = mountView() + await triggerWSOpen() + expect(wrapper.text()).toContain('Start Simulator') + }) + + it('should show "Stop Simulator" with version when started', async () => { + mockClient.simulatorState = vi.fn().mockResolvedValue({ + state: { started: true, templateStatistics: {}, version: '1.5.0' }, + status: ResponseStatus.SUCCESS, + }) + const wrapper = mountView() + await triggerWSOpen() + expect(wrapper.text()).toContain('Stop Simulator') + expect(wrapper.text()).toContain('1.5.0') + }) + + it('should show "Start Simulator" without version initially', () => { + const wrapper = mountView() + expect(wrapper.text()).toContain('Start Simulator') + expect(wrapper.text()).not.toContain('(') + }) + }) + + describe('reload button', () => { + it('should call listChargingStations when reload button clicked', async () => { + const wrapper = mountView() + const reloadButton = wrapper.findComponent({ name: 'ReloadButton' }) + await reloadButton.trigger('click') + await flushPromises() + expect(mockClient.listChargingStations).toHaveBeenCalled() + }) + }) + + describe('CSTable visibility', () => { + it('should hide CSTable when no charging stations', () => { + const wrapper = mountView({ chargingStations: [] }) + const csTable = wrapper.findComponent({ name: 'CSTable' }) + expect(csTable.exists()).toBe(true) + expect((csTable.element as HTMLElement).style.display).toBe('none') + }) + + it('should show CSTable when charging stations exist', () => { + const wrapper = mountView({ + chargingStations: [createChargingStationData()], + }) + const csTable = wrapper.findComponent({ name: 'CSTable' }) + expect(csTable.exists()).toBe(true) + expect((csTable.element as HTMLElement).style.display).not.toBe('none') + }) + }) + + describe('UI server selector', () => { + it('should hide server selector for single server configuration', () => { + const wrapper = mountView({ configuration: singleServerConfig }) + const selectorContainer = wrapper.find('#ui-server-container') + expect(selectorContainer.exists()).toBe(true) + expect((selectorContainer.element as HTMLElement).style.display).toBe('none') + }) + + it('should show server selector for multiple server configuration', () => { + const wrapper = mountView({ configuration: multiServerConfig }) + const selectorContainer = wrapper.find('#ui-server-container') + expect(selectorContainer.exists()).toBe(true) + expect((selectorContainer.element as HTMLElement).style.display).not.toBe('none') + }) + + it('should render an option for each server', () => { + const wrapper = mountView({ configuration: multiServerConfig }) + const options = wrapper.findAll('#ui-server-selector option') + expect(options).toHaveLength(2) + }) + + it('should display server name in options', () => { + const wrapper = mountView({ configuration: multiServerConfig }) + const options = wrapper.findAll('#ui-server-selector option') + expect(options[0].text()).toContain('Server 1') + expect(options[1].text()).toContain('Server 2') + }) + + it('should fall back to host when server name is missing', () => { + const config = { + uiServer: [ + createUIServerConfig({ host: 'host-a' }), + createUIServerConfig({ host: 'host-b' }), + ], + } + const wrapper = mountView({ configuration: config }) + const options = wrapper.findAll('#ui-server-selector option') + expect(options[0].text()).toContain('host-a') + expect(options[1].text()).toContain('host-b') + }) + }) + + describe('start/stop simulator', () => { + it('should show success toast when simulator starts', async () => { + mockClient.startSimulator = vi.fn().mockResolvedValue({ + status: ResponseStatus.SUCCESS, + }) + const wrapper = mountView() + const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' }) + const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator') + const onProp = simulatorButton?.props('on') as (() => void) | undefined + onProp?.() + await flushPromises() + expect(mockClient.startSimulator).toHaveBeenCalled() + expect(toastMock.success).toHaveBeenCalledWith('Simulator successfully started') + }) + + it('should show error toast when simulator start fails', async () => { + mockClient.startSimulator = vi.fn().mockRejectedValue(new Error('start failed')) + const wrapper = mountView() + const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' }) + const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator') + const onProp = simulatorButton?.props('on') as (() => void) | undefined + onProp?.() + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Error at starting simulator') + }) + + it('should show success toast when simulator stops', async () => { + mockClient.stopSimulator = vi.fn().mockResolvedValue({ + status: ResponseStatus.SUCCESS, + }) + const wrapper = mountView() + const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' }) + const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator') + const offProp = simulatorButton?.props('off') as (() => void) | undefined + offProp?.() + await flushPromises() + expect(mockClient.stopSimulator).toHaveBeenCalled() + expect(toastMock.success).toHaveBeenCalledWith('Simulator successfully stopped') + }) + + it('should show error toast when simulator stop fails', async () => { + mockClient.stopSimulator = vi.fn().mockRejectedValue(new Error('stop failed')) + const wrapper = mountView() + const toggleButtons = wrapper.findAllComponents({ name: 'ToggleButton' }) + const simulatorButton = toggleButtons.find(tb => tb.props('id') === 'simulator') + const offProp = simulatorButton?.props('off') as (() => void) | undefined + offProp?.() + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Error at stopping simulator') + }) + }) + + describe('error handling', () => { + it('should show error toast when listChargingStations fails', async () => { + mockClient.listChargingStations = vi.fn().mockRejectedValue(new Error('Network error')) + mountView() + await triggerWSOpen() + expect(toastMock.error).toHaveBeenCalledWith('Error at fetching charging stations') + }) + + it('should show error toast when listTemplates fails', async () => { + mockClient.listTemplates = vi.fn().mockRejectedValue(new Error('Template error')) + mountView() + await triggerWSOpen() + expect(toastMock.error).toHaveBeenCalledWith('Error at fetching charging station templates') + }) + + it('should show error toast when simulatorState fails', async () => { + mockClient.simulatorState = vi.fn().mockRejectedValue(new Error('State error')) + mountView() + await triggerWSOpen() + expect(toastMock.error).toHaveBeenCalledWith('Error at fetching simulator state') + }) + }) + + describe('server switching', () => { + it('should call setConfiguration when server index changes', async () => { + const wrapper = mountView({ configuration: multiServerConfig }) + const selector = wrapper.find('#ui-server-selector') + await selector.setValue(1) + expect(mockClient.setConfiguration).toHaveBeenCalled() + }) + + it('should register new WS event listeners after server switch', async () => { + const wrapper = mountView({ configuration: multiServerConfig }) + // Reset call count from initial mount registration + vi.mocked(mockClient.registerWSEventListener).mockClear() + const selector = wrapper.find('#ui-server-selector') + await selector.setValue(1) + // registerWSEventListeners called again + expect(mockClient.registerWSEventListener).toHaveBeenCalledWith('open', expect.any(Function)) + }) + + it('should save server index to localStorage on successful switch', async () => { + const wrapper = mountView({ configuration: multiServerConfig }) + const selector = wrapper.find('#ui-server-selector') + await selector.setValue(1) + // Simulate the WS open for the new connection (once-listener from server switching) + const onceOpenCalls = vi + .mocked(mockClient.registerWSEventListener) + .mock.calls.filter( + ([event, , options]) => + event === 'open' && + (options as undefined | { once?: boolean }) != null && + (options as { once?: boolean }).once === true + ) + const onceOpenHandler = onceOpenCalls[onceOpenCalls.length - 1]?.[1] as + | (() => void) + | undefined + onceOpenHandler?.() + await flushPromises() + expect(localStorage.getItem('uiServerConfigurationIndex')).toBe('1') + }) + + it('should revert server index on connection error', async () => { + localStorage.setItem('uiServerConfigurationIndex', '0') + const wrapper = mountView({ configuration: multiServerConfig }) + const selector = wrapper.find('#ui-server-selector') + await selector.setValue(1) + // Find the once-error listener + const onceErrorCalls = vi + .mocked(mockClient.registerWSEventListener) + .mock.calls.filter( + ([event, , options]) => + event === 'error' && + (options as undefined | { once?: boolean }) != null && + (options as { once?: boolean }).once === true + ) + const onceErrorHandler = onceErrorCalls[onceErrorCalls.length - 1]?.[1] as + | (() => void) + | undefined + onceErrorHandler?.() + await flushPromises() + // Should revert to index 0 + expect(mockClient.setConfiguration).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/ui/web/tests/unit/SetSupervisionUrl.test.ts b/ui/web/tests/unit/SetSupervisionUrl.test.ts new file mode 100644 index 00000000..b8bd56a6 --- /dev/null +++ b/ui/web/tests/unit/SetSupervisionUrl.test.ts @@ -0,0 +1,83 @@ +/** + * @file Tests for SetSupervisionUrl component + * @description Unit tests for supervision URL form — display, submission, and navigation. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import SetSupervisionUrl from '@/components/actions/SetSupervisionUrl.vue' + +import { toastMock } from '../setup' +import { TEST_HASH_ID, TEST_STATION_ID } from './constants' +import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers' + +describe('SetSupervisionUrl', () => { + let mockClient: MockUIClient + let mockRouter: { push: ReturnType } + + /** + * Mounts SetSupervisionUrl with mock UIClient, router, and toast. + * @param props - Props to override defaults + * @returns Mounted component wrapper + */ + function mountComponent (props = {}) { + mockClient = createMockUIClient() + mockRouter = { push: vi.fn() } + return mount(SetSupervisionUrl, { + global: { + config: { + globalProperties: { + $router: mockRouter, + $toast: toastMock, + $uiClient: mockClient, + } as never, + }, + stubs: { + Button: ButtonStub, + }, + }, + props: { + chargingStationId: TEST_STATION_ID, + hashId: TEST_HASH_ID, + ...props, + }, + }) + } + + it('should display the charging station ID', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain(TEST_STATION_ID) + }) + + it('should render supervision URL input', () => { + const wrapper = mountComponent() + expect(wrapper.find('#supervision-url').exists()).toBe(true) + }) + + it('should call setSupervisionUrl on button click', async () => { + const wrapper = mountComponent() + const input = wrapper.find('#supervision-url') + await input.setValue('wss://new-server.com:9000') + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.setSupervisionUrl).toHaveBeenCalledWith( + TEST_HASH_ID, + 'wss://new-server.com:9000' + ) + }) + + it('should navigate to charging-stations after submission', async () => { + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' }) + }) + + it('should show error toast on failure', async () => { + const wrapper = mountComponent() + mockClient.setSupervisionUrl = vi.fn().mockRejectedValue(new Error('Network error')) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalled() + }) +}) diff --git a/ui/web/tests/unit/SimpleComponents.test.ts b/ui/web/tests/unit/SimpleComponents.test.ts new file mode 100644 index 00000000..aa0f4d15 --- /dev/null +++ b/ui/web/tests/unit/SimpleComponents.test.ts @@ -0,0 +1,96 @@ +/** + * @file Tests for simple presentational components + * @description Unit tests for Button, Container, ReloadButton, NotFoundView, and App. + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import { defineComponent } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' + +import App from '@/App.vue' +import Button from '@/components/buttons/Button.vue' +import ReloadButton from '@/components/buttons/ReloadButton.vue' +import Container from '@/components/Container.vue' +import NotFoundView from '@/views/NotFoundView.vue' + +const DummyComponent = defineComponent({ template: '
' }) + +describe('Button', () => { + it('should render slot content', () => { + const wrapper = mount(Button, { slots: { default: 'Click me' } }) + expect(wrapper.text()).toContain('Click me') + }) + + it('should render a button element', () => { + const wrapper = mount(Button) + expect(wrapper.find('button').exists()).toBe(true) + }) +}) + +describe('Container', () => { + it('should render slot content', () => { + const wrapper = mount(Container, { slots: { default: 'Content' } }) + expect(wrapper.text()).toContain('Content') + }) + + it('should apply container class', () => { + const wrapper = mount(Container) + expect(wrapper.classes()).toContain('container') + }) +}) + +describe('ReloadButton', () => { + it('should render reload icon', () => { + const wrapper = mount(ReloadButton, { props: { loading: false } }) + expect(wrapper.find('span').exists()).toBe(true) + }) + + it('should apply spin class when loading is true', () => { + const wrapper = mount(ReloadButton, { props: { loading: true } }) + expect(wrapper.find('span').classes()).toContain('spin') + }) + + it('should not apply spin class when loading is false', () => { + const wrapper = mount(ReloadButton, { props: { loading: false } }) + expect(wrapper.find('span').classes()).not.toContain('spin') + }) +}) + +describe('NotFoundView', () => { + it('should render 404 message', () => { + const wrapper = mount(NotFoundView) + expect(wrapper.text()).toContain('404') + expect(wrapper.text()).toContain('Not found') + }) +}) + +describe('App', () => { + it('should render Container component', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ component: DummyComponent, name: 'charging-stations', path: '/' }], + }) + await router.push('/') + await router.isReady() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + expect(wrapper.findComponent(Container).exists()).toBe(true) + }) + + it('should hide action container on charging-stations route', async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ component: DummyComponent, name: 'charging-stations', path: '/' }], + }) + await router.push('/') + await router.isReady() + const wrapper = mount(App, { + global: { plugins: [router] }, + }) + const actionContainer = wrapper.find('#action-container') + // v-show hides via inline style display:none + const element = actionContainer.element as HTMLElement + expect(element.style.display).toBe('none') + }) +}) diff --git a/ui/web/tests/unit/StartTransaction.test.ts b/ui/web/tests/unit/StartTransaction.test.ts new file mode 100644 index 00000000..26c4aaf1 --- /dev/null +++ b/ui/web/tests/unit/StartTransaction.test.ts @@ -0,0 +1,192 @@ +/** + * @file Tests for StartTransaction component + * @description Unit tests for start transaction form — OCPP version branching, authorization flow, and navigation. + */ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import type { UIClient } from '@/composables/UIClient' + +import StartTransaction from '@/components/actions/StartTransaction.vue' +import { useUIClient } from '@/composables' +import { OCPPVersion } from '@/types' + +import { toastMock } from '../setup' +import { TEST_HASH_ID, TEST_ID_TAG, TEST_STATION_ID } from './constants' +import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers' + +vi.mock('@/composables', async importOriginal => { + const actual: Record = await importOriginal() + return { ...actual, useUIClient: vi.fn() } +}) + +vi.mock('vue-router', async importOriginal => { + const actual: Record = 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 } + + /** + * Mounts StartTransaction with mock UIClient, router, and route query. + * @param routeQuery - Route query parameters + * @returns Mounted component wrapper + */ + function mountComponent (routeQuery: Record = {}) { + mockClient = createMockUIClient() + mockRouter = { push: vi.fn() } + vi.mocked(useUIClient).mockReturnValue(mockClient as unknown as UIClient) + vi.mocked(useRouter).mockReturnValue(mockRouter as unknown as ReturnType) + vi.mocked(useRoute).mockReturnValue({ + name: 'start-transaction', + params: { + chargingStationId: TEST_STATION_ID, + connectorId: '1', + hashId: TEST_HASH_ID, + }, + query: routeQuery, + } as unknown as ReturnType) + return mount(StartTransaction, { + global: { + stubs: { + Button: ButtonStub, + }, + }, + props: { + chargingStationId: TEST_STATION_ID, + connectorId: '1', + hashId: TEST_HASH_ID, + }, + }) + } + + describe('display', () => { + it('should display charging station ID', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain(TEST_STATION_ID) + }) + + it('should display connector ID without EVSE when no evseId', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Connector 1') + expect(wrapper.text()).not.toContain('EVSE') + }) + + it('should display EVSE and connector when evseId in query', () => { + const wrapper = mountComponent({ + evseId: '2', + ocppVersion: OCPPVersion.VERSION_20, + }) + expect(wrapper.text()).toContain('EVSE 2') + }) + + it('should show authorize checkbox for OCPP 1.6', () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 }) + expect(wrapper.find('[type="checkbox"]').exists()).toBe(true) + }) + + it('should hide authorize checkbox for OCPP 2.0.x', () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_20 }) + expect(wrapper.find('[type="checkbox"]').exists()).toBe(false) + }) + }) + + describe('OCPP 1.6 transaction flow', () => { + it('should call startTransaction for OCPP 1.6', async () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 }) + await wrapper.find('#idtag').setValue(TEST_ID_TAG) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.startTransaction).toHaveBeenCalledWith(TEST_HASH_ID, { + connectorId: 1, + evseId: undefined, + idTag: TEST_ID_TAG, + ocppVersion: OCPPVersion.VERSION_16, + }) + }) + + it('should call authorize before startTransaction when authorize checked', async () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 }) + await wrapper.find('#idtag').setValue(TEST_ID_TAG) + await wrapper.find('[type="checkbox"]').setValue(true) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.authorize).toHaveBeenCalledWith(TEST_HASH_ID, TEST_ID_TAG) + expect(mockClient.startTransaction).toHaveBeenCalled() + }) + + it('should show error toast when authorize checked but no idTag provided', async () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 }) + await wrapper.find('[type="checkbox"]').setValue(true) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Please provide an RFID tag to authorize') + expect(mockClient.startTransaction).not.toHaveBeenCalled() + }) + + it('should show error toast and navigate when authorize call fails', async () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_16 }) + mockClient.authorize = vi.fn().mockRejectedValue(new Error('Auth failed')) + await wrapper.find('#idtag').setValue(TEST_ID_TAG) + await wrapper.find('[type="checkbox"]').setValue(true) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalledWith('Error at authorizing RFID tag') + expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' }) + expect(mockClient.startTransaction).not.toHaveBeenCalled() + }) + }) + + describe('OCPP 2.0.x transaction flow', () => { + it('should call startTransaction for OCPP 2.0.x', async () => { + const wrapper = mountComponent({ ocppVersion: OCPPVersion.VERSION_20 }) + await wrapper.find('#idtag').setValue(TEST_ID_TAG) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.startTransaction).toHaveBeenCalledWith(TEST_HASH_ID, { + connectorId: 1, + evseId: undefined, + idTag: TEST_ID_TAG, + ocppVersion: OCPPVersion.VERSION_20, + }) + }) + + it('should pass evseId when available', async () => { + const wrapper = mountComponent({ + evseId: '2', + ocppVersion: OCPPVersion.VERSION_20, + }) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockClient.startTransaction).toHaveBeenCalledWith( + TEST_HASH_ID, + expect.objectContaining({ evseId: 2 }) + ) + }) + }) + + describe('navigation', () => { + it('should navigate back after transaction', async () => { + const wrapper = mountComponent() + await wrapper.find('button').trigger('click') + await flushPromises() + expect(mockRouter.push).toHaveBeenCalledWith({ name: 'charging-stations' }) + }) + + it('should show error toast on failure', async () => { + const wrapper = mountComponent() + mockClient.startTransaction = vi.fn().mockRejectedValue(new Error('Failed')) + await wrapper.find('button').trigger('click') + await flushPromises() + expect(toastMock.error).toHaveBeenCalled() + }) + }) +}) diff --git a/ui/web/tests/unit/ToggleButton.test.ts b/ui/web/tests/unit/ToggleButton.test.ts new file mode 100644 index 00000000..2314e7e5 --- /dev/null +++ b/ui/web/tests/unit/ToggleButton.test.ts @@ -0,0 +1,237 @@ +/** + * @file Tests for ToggleButton component + * @description Unit tests for toggle state, localStorage persistence, shared toggle behavior, and callbacks. + */ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import ToggleButton from '@/components/buttons/ToggleButton.vue' + +/** + * Mount factory — stubs Button child component with slot passthrough + * @param props - Component props + * @param props.id - Unique identifier for the toggle + * @param props.off - Callback when toggled off + * @param props.on - Callback when toggled on + * @param props.shared - Whether this is a shared toggle + * @param props.status - Initial toggle status + * @returns Mounted wrapper + */ +function mountToggleButton ( + props: { + id: string + off?: () => void + on?: () => void + shared?: boolean + status?: boolean + } = { id: 'test-toggle' } +) { + return mount(ToggleButton, { + global: { + stubs: { + Button: { + template: '', + }, + }, + }, + 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') + }) + }) +}) diff --git a/ui/web/tests/unit/UIClient.test.ts b/ui/web/tests/unit/UIClient.test.ts index 2f61ab84..41ec2b96 100644 --- a/ui/web/tests/unit/UIClient.test.ts +++ b/ui/web/tests/unit/UIClient.test.ts @@ -1,49 +1,56 @@ +/** + * @file Tests for UIClient composable + * @description Unit tests for WebSocket client singleton, connection lifecycle, + * request/response handling, and all simulator/station operations. + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { UIClient } from '@/composables/UIClient' import { + AuthenticationType, OCPP20TransactionEventEnumType, OCPPVersion, - Protocol, - ProtocolVersion, + ProcedureName, ResponseStatus, } from '@/types' -vi.mock('vue-toast-notification', () => ({ - useToast: () => ({ - error: vi.fn(), - info: vi.fn(), - success: vi.fn(), - }), -})) - -class MockWebSocket { - addEventListener = vi.fn() - close = vi.fn() - onclose: (() => void) | null = null - onerror: ((event: Event) => void) | null = null - onmessage: ((event: MessageEvent) => void) | null = null - - onopen: (() => void) | null = null - - readyState = WebSocket.OPEN - removeEventListener = vi.fn() - send = vi.fn() - constructor () { - setTimeout(() => { - this.onopen?.() - }, 0) - } -} - -const mockConfig = { - host: 'localhost', - port: 8080, - protocol: Protocol.UI, - version: ProtocolVersion['0.0.1'], -} +import { toastMock } from '../setup' +import { createUIServerConfig, TEST_HASH_ID, TEST_ID_TAG } from './constants' +import { MockWebSocket } from './helpers' + +// Reset singleton between tests +beforeEach(() => { + // @ts-expect-error — accessing private static property for testing + UIClient.instance = null + vi.stubGlobal('WebSocket', MockWebSocket) +}) + +afterEach(() => { + vi.unstubAllGlobals() + // @ts-expect-error — accessing private static property for testing + UIClient.instance = null +}) describe('UIClient', () => { + describe('singleton pattern', () => { + it('should create instance when config is provided', () => { + const client = UIClient.getInstance(createUIServerConfig()) + expect(client).toBeInstanceOf(UIClient) + }) + + it('should throw when no config and no existing instance', () => { + expect(() => UIClient.getInstance()).toThrow( + 'Cannot initialize UIClient if no configuration is provided' + ) + }) + + it('should return same instance on subsequent calls without config', () => { + const first = UIClient.getInstance(createUIServerConfig()) + const second = UIClient.getInstance() + expect(second).toBe(first) + }) + }) + describe('isOCPP20x', () => { it('should return true for VERSION_20', () => { expect(UIClient.isOCPP20x(OCPPVersion.VERSION_20)).toBe(true) @@ -62,109 +69,475 @@ describe('UIClient', () => { }) }) - describe('version-aware transaction methods', () => { + describe('WebSocket connection', () => { + it('should connect with ws:// URL format', () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + expect(ws.url).toBe('ws://localhost:8080') + }) + + it('should connect with wss:// when secure is true', () => { + const client = UIClient.getInstance(createUIServerConfig({ secure: true })) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + expect(ws.url).toBe('wss://localhost:8080') + }) + + it('should use protocol version as subprotocol without auth', () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + expect(ws.protocols).toBe('ui0.0.1') + }) + + it('should include basic auth credentials in subprotocol', () => { + const config = createUIServerConfig({ + authentication: { + enabled: true, + password: 'pass', + type: AuthenticationType.PROTOCOL_BASIC_AUTH, + username: 'user', + }, + }) + const client = UIClient.getInstance(config) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + expect(ws.protocols).toBeInstanceOf(Array) + const protocols = ws.protocols as string[] + expect(protocols[0]).toBe('ui0.0.1') + expect(protocols[1]).toMatch(/^authorization\.basic\./) + }) + + it('should show success toast on WebSocket open', () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateOpen() + expect(toastMock.success).toHaveBeenCalledWith(expect.stringContaining('successfully opened')) + }) + + it('should log error on WebSocket error', () => { + const consoleSpy = vi.spyOn(console, 'error') + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateError() + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Error in WebSocket'), + expect.any(Event) + ) + }) + + it('should handle WebSocket close event', () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateClose() + }) + }) + + describe('request/response handling', () => { + it('should resolve promise on SUCCESS response', async () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateOpen() + + const promise = client.listChargingStations() + const [uuid] = JSON.parse(ws.sentMessages[0]) as [string] + ws.simulateMessage([uuid, { status: ResponseStatus.SUCCESS }]) + + const result = await promise + expect(result.status).toBe(ResponseStatus.SUCCESS) + }) + + it('should reject promise on FAILURE response', async () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateOpen() + + const promise = client.listChargingStations() + const [uuid] = JSON.parse(ws.sentMessages[0]) as [string] + ws.simulateMessage([uuid, { status: ResponseStatus.FAILURE }]) + + await expect(promise).rejects.toEqual( + expect.objectContaining({ status: ResponseStatus.FAILURE }) + ) + }) + + it('should reject with Error on unknown response status', async () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateOpen() + + const promise = client.listChargingStations() + const [uuid] = JSON.parse(ws.sentMessages[0]) as [string] + ws.simulateMessage([uuid, { status: 'unknown' }]) + + await expect(promise).rejects.toThrow(/not supported/) + }) + + it('should reject when WebSocket is not open', async () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.readyState = WebSocket.CLOSED + + await expect(client.listChargingStations()).rejects.toThrow('connection closed') + }) + + it('should reject when ws.send throws', async () => { + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + ws.simulateOpen() + ws.send.mockImplementation(() => { + throw new Error('send failed') + }) + + await expect(client.startSimulator()).rejects.toThrow('error Error: send failed') + }) + + it('should handle invalid JSON response gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error') + const client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private property for testing + const ws = client.ws as MockWebSocket + + ws.onmessage?.({ data: 'not json' } as MessageEvent) + + 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 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 + + 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 + + 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 + + beforeEach(() => { + client = UIClient.getInstance(createUIServerConfig()) + // @ts-expect-error — accessing private method for testing + sendRequestSpy = vi.spyOn(client, 'sendRequest').mockResolvedValue({ + status: ResponseStatus.SUCCESS, + }) }) describe('startTransaction', () => { it('should send START_TRANSACTION for OCPP 1.6', async () => { - await client.startTransaction('hash123', { + await client.startTransaction(TEST_HASH_ID, { connectorId: 1, - idTag: 'idTag123', + idTag: TEST_ID_TAG, ocppVersion: OCPPVersion.VERSION_16, }) - expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_TRANSACTION, { connectorId: 1, - hashIds: ['hash123'], - idTag: 'idTag123', + hashIds: [TEST_HASH_ID], + idTag: TEST_ID_TAG, }) }) it('should send TRANSACTION_EVENT with evse object for OCPP 2.0.x', async () => { - await client.startTransaction('hash123', { + await client.startTransaction(TEST_HASH_ID, { connectorId: 2, evseId: 1, - idTag: 'idTag123', + idTag: TEST_ID_TAG, ocppVersion: OCPPVersion.VERSION_20, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.STARTED, evse: { connectorId: 2, id: 1 }, - hashIds: ['hash123'], - idToken: { idToken: 'idTag123', type: 'ISO14443' }, + hashIds: [TEST_HASH_ID], + idToken: { idToken: TEST_ID_TAG, type: 'ISO14443' }, }) }) it('should default to OCPP 1.6 when version is undefined', async () => { - await client.startTransaction('hash123', { connectorId: 1, idTag: 'idTag123' }) + await client.startTransaction(TEST_HASH_ID, { + connectorId: 1, + idTag: TEST_ID_TAG, + }) - expect(sendRequestSpy).toHaveBeenCalledWith('startTransaction', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.START_TRANSACTION, { connectorId: 1, - hashIds: ['hash123'], - idTag: 'idTag123', + hashIds: [TEST_HASH_ID], + idTag: TEST_ID_TAG, }) }) it('should send undefined evse when evseId is not provided for OCPP 2.0.x', async () => { - await client.startTransaction('hash123', { + await client.startTransaction(TEST_HASH_ID, { connectorId: 1, - idTag: 'idTag123', + idTag: TEST_ID_TAG, ocppVersion: OCPPVersion.VERSION_20, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.STARTED, evse: undefined, - hashIds: ['hash123'], - idToken: { idToken: 'idTag123', type: 'ISO14443' }, + hashIds: [TEST_HASH_ID], + idToken: { idToken: TEST_ID_TAG, type: 'ISO14443' }, }) }) it('should send undefined idToken when idTag is not provided for OCPP 2.0.x', async () => { - await client.startTransaction('hash123', { + await client.startTransaction(TEST_HASH_ID, { connectorId: 1, evseId: 1, ocppVersion: OCPPVersion.VERSION_20, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.STARTED, evse: { connectorId: 1, id: 1 }, - hashIds: ['hash123'], + hashIds: [TEST_HASH_ID], idToken: undefined, }) }) it('should send undefined evse and idToken when both absent for OCPP 2.0.x', async () => { - await client.startTransaction('hash123', { + await client.startTransaction(TEST_HASH_ID, { connectorId: 1, ocppVersion: OCPPVersion.VERSION_20, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.STARTED, evse: undefined, - hashIds: ['hash123'], + hashIds: [TEST_HASH_ID], idToken: undefined, }) }) @@ -172,67 +545,67 @@ describe('UIClient', () => { describe('stopTransaction', () => { it('should send STOP_TRANSACTION for OCPP 1.6', async () => { - await client.stopTransaction('hash123', { + await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_16, transactionId: 12345, }) - expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', { - hashIds: ['hash123'], + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, { + hashIds: [TEST_HASH_ID], transactionId: 12345, }) }) it('should send TRANSACTION_EVENT with Ended for OCPP 2.0.x', async () => { - await client.stopTransaction('hash123', { + await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_20, transactionId: 'tx-uuid-123', }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.ENDED, - hashIds: ['hash123'], + hashIds: [TEST_HASH_ID], transactionId: 'tx-uuid-123', }) }) it('should default to OCPP 1.6 when version is undefined', async () => { - await client.stopTransaction('hash123', { transactionId: 12345 }) + await client.stopTransaction(TEST_HASH_ID, { transactionId: 12345 }) - expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', { - hashIds: ['hash123'], + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, { + hashIds: [TEST_HASH_ID], transactionId: 12345, }) }) it('should send undefined transactionId for OCPP 2.0.x when not provided', async () => { - await client.stopTransaction('hash123', { + await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_20, transactionId: undefined, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.ENDED, - hashIds: ['hash123'], + hashIds: [TEST_HASH_ID], transactionId: undefined, }) }) it('should convert numeric transactionId to string for OCPP 2.0.x', async () => { - await client.stopTransaction('hash123', { + await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_20, transactionId: 12345, }) - expect(sendRequestSpy).toHaveBeenCalledWith('transactionEvent', { + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.TRANSACTION_EVENT, { eventType: OCPP20TransactionEventEnumType.ENDED, - hashIds: ['hash123'], + hashIds: [TEST_HASH_ID], transactionId: '12345', }) }) it('should return failure for string transactionId with OCPP 1.6', async () => { - const result = await client.stopTransaction('hash123', { + const result = await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_16, transactionId: 'string-id', }) @@ -242,13 +615,13 @@ describe('UIClient', () => { }) it('should send undefined transactionId for OCPP 1.6 when not provided', async () => { - await client.stopTransaction('hash123', { + await client.stopTransaction(TEST_HASH_ID, { ocppVersion: OCPPVersion.VERSION_16, transactionId: undefined, }) - expect(sendRequestSpy).toHaveBeenCalledWith('stopTransaction', { - hashIds: ['hash123'], + expect(sendRequestSpy).toHaveBeenCalledWith(ProcedureName.STOP_TRANSACTION, { + hashIds: [TEST_HASH_ID], transactionId: undefined, }) }) diff --git a/ui/web/tests/unit/Utils.test.ts b/ui/web/tests/unit/Utils.test.ts new file mode 100644 index 00000000..d606d1c1 --- /dev/null +++ b/ui/web/tests/unit/Utils.test.ts @@ -0,0 +1,322 @@ +/** + * @file Tests for Utils composable + * @description Unit tests for type conversion, localStorage, UUID, and toggle state utilities. + */ +import { afterEach, describe, expect, it } from 'vitest' + +import { + convertToBoolean, + convertToInt, + deleteFromLocalStorage, + getFromLocalStorage, + getLocalStorage, + randomUUID, + resetToggleButtonState, + setToLocalStorage, + validateUUID, +} from '@/composables/Utils' + +describe('Utils', () => { + describe('convertToBoolean', () => { + it('should return true for boolean true', () => { + expect(convertToBoolean(true)).toBe(true) + }) + + it('should return false for boolean false', () => { + expect(convertToBoolean(false)).toBe(false) + }) + + it('should return true for string "true"', () => { + expect(convertToBoolean('true')).toBe(true) + }) + + it('should return true for string "True" (case-insensitive)', () => { + expect(convertToBoolean('True')).toBe(true) + }) + + it('should return true for string "1"', () => { + expect(convertToBoolean('1')).toBe(true) + }) + + it('should return true for numeric 1', () => { + expect(convertToBoolean(1)).toBe(true) + }) + + it('should return false for string "false"', () => { + expect(convertToBoolean('false')).toBe(false) + }) + + it('should return false for string "0"', () => { + expect(convertToBoolean('0')).toBe(false) + }) + + it('should return false for numeric 0', () => { + expect(convertToBoolean(0)).toBe(false) + }) + + it('should return false for null', () => { + expect(convertToBoolean(null)).toBe(false) + }) + + it('should return false for undefined', () => { + expect(convertToBoolean(undefined)).toBe(false) + }) + + it('should return false for empty string', () => { + expect(convertToBoolean('')).toBe(false) + }) + + it('should return false for arbitrary string', () => { + expect(convertToBoolean('random')).toBe(false) + }) + + it('should return false for numeric 2', () => { + expect(convertToBoolean(2)).toBe(false) + }) + }) + + describe('convertToInt', () => { + it('should return integer for integer input', () => { + expect(convertToInt(42)).toBe(42) + }) + + it('should truncate float to integer', () => { + expect(convertToInt(42.7)).toBe(42) + }) + + it('should truncate negative float to integer', () => { + expect(convertToInt(-42.7)).toBe(-42) + }) + + it('should parse string integer', () => { + expect(convertToInt('42')).toBe(42) + }) + + it('should parse negative string integer', () => { + expect(convertToInt('-42')).toBe(-42) + }) + + it('should return 0 for null', () => { + expect(convertToInt(null)).toBe(0) + }) + + it('should return 0 for undefined', () => { + expect(convertToInt(undefined)).toBe(0) + }) + + it('should throw error for non-numeric string', () => { + expect(() => convertToInt('abc')).toThrow(Error) + expect(() => convertToInt('abc')).toThrow("Cannot convert to integer: 'abc'") + }) + + it('should throw error for empty string', () => { + expect(() => convertToInt('')).toThrow(Error) + }) + + it('should return NaN for NaN input', () => { + const result = convertToInt(Number.NaN) + expect(Number.isNaN(result)).toBe(true) + }) + }) + + describe('localStorage utilities', () => { + afterEach(() => { + localStorage.clear() + }) + + it('should get value from localStorage when key exists', () => { + // Arrange + const key = 'test-key' + const value = { count: 42, name: 'test' } + localStorage.setItem(key, JSON.stringify(value)) + + // Act + const result = getFromLocalStorage(key, null) + + // Assert + expect(result).toEqual(value) + }) + + it('should return default value when key does not exist', () => { + // Arrange + const key = 'non-existent-key' + const defaultValue = { name: 'default' } + + // Act + const result = getFromLocalStorage(key, defaultValue) + + // Assert + expect(result).toEqual(defaultValue) + }) + + it('should set value to localStorage', () => { + // Arrange + const key = 'test-key' + const value = { count: 42, name: 'test' } + + // Act + setToLocalStorage(key, value) + + // Assert + expect(localStorage.getItem(key)).toBe(JSON.stringify(value)) + }) + + it('should set string value to localStorage', () => { + // Arrange + const key = 'string-key' + const value = 'test-string' + + // Act + setToLocalStorage(key, value) + + // Assert + expect(localStorage.getItem(key)).toBe(JSON.stringify(value)) + }) + + it('should delete value from localStorage', () => { + // Arrange + const key = 'test-key' + localStorage.setItem(key, 'test-value') + + // Act + deleteFromLocalStorage(key) + + // Assert + expect(localStorage.getItem(key)).toBeNull() + }) + + it('should return localStorage instance', () => { + // Act + const result = getLocalStorage() + + // Assert + expect(result).toBe(localStorage) + }) + }) + + describe('UUID', () => { + it('should generate valid UUID v4', () => { + // Act + const uuid = randomUUID() + + // Assert + expect(validateUUID(uuid)).toBe(true) + }) + + it('should generate different UUIDs on each call', () => { + // Act + const uuid1 = randomUUID() + const uuid2 = randomUUID() + + // Assert + expect(uuid1).not.toBe(uuid2) + }) + + it('should validate correct UUID v4 format', () => { + // Arrange + const validUUID = '550e8400-e29b-41d4-a716-446655440000' + + // Act + const result = validateUUID(validUUID) + + // Assert + expect(result).toBe(true) + }) + + it('should reject non-string UUID', () => { + // Act + const result = validateUUID(123) + + // Assert + expect(result).toBe(false) + }) + + it('should reject invalid UUID format', () => { + // Act + const result = validateUUID('not-a-uuid') + + // Assert + expect(result).toBe(false) + }) + + it('should reject UUID with wrong version (v3 instead of v4)', () => { + // Arrange + const v3UUID = '550e8400-e29b-31d4-a716-446655440000' + + // Act + const result = validateUUID(v3UUID) + + // Assert + expect(result).toBe(false) + }) + + it('should reject UUID with invalid variant', () => { + // Arrange + const invalidVariantUUID = '550e8400-e29b-41d4-c716-446655440000' + + // Act + const result = validateUUID(invalidVariantUUID) + + // Assert + expect(result).toBe(false) + }) + }) + + describe('resetToggleButtonState', () => { + afterEach(() => { + localStorage.clear() + }) + + it('should delete non-shared toggle button state with correct key', () => { + // Arrange + const id = 'button-1' + const key = `toggle-button-${id}` + localStorage.setItem(key, 'true') + + // Act + resetToggleButtonState(id, false) + + // Assert + expect(localStorage.getItem(key)).toBeNull() + }) + + it('should delete shared toggle button state with correct key', () => { + // Arrange + const id = 'button-1' + const key = `shared-toggle-button-${id}` + localStorage.setItem(key, 'true') + + // Act + resetToggleButtonState(id, true) + + // Assert + expect(localStorage.getItem(key)).toBeNull() + }) + + it('should use non-shared key by default', () => { + // Arrange + const id = 'button-1' + const nonSharedKey = `toggle-button-${id}` + const sharedKey = `shared-toggle-button-${id}` + localStorage.setItem(nonSharedKey, 'true') + localStorage.setItem(sharedKey, 'true') + + // Act + resetToggleButtonState(id) + + // Assert + expect(localStorage.getItem(nonSharedKey)).toBeNull() + expect(localStorage.getItem(sharedKey)).toBe('true') + }) + + it('should handle deletion of non-existent key gracefully', () => { + // Arrange + const id = 'non-existent' + + // Act & Assert + expect(() => { + resetToggleButtonState(id) + }).not.toThrow() + }) + }) +}) diff --git a/ui/web/tests/unit/constants.ts b/ui/web/tests/unit/constants.ts new file mode 100644 index 00000000..62863338 --- /dev/null +++ b/ui/web/tests/unit/constants.ts @@ -0,0 +1,117 @@ +/** + * @file Test data factories for Vue.js web UI unit tests + * @description Factory functions (NOT static objects) for ChargingStationData, + * ConnectorStatus, and other test fixtures. Using factories prevents shared state. + */ +import { + type ChargingStationData, + type ChargingStationInfo, + type ConnectorStatus, + type EvseEntry, + OCPPVersion, + Protocol, + ProtocolVersion, + type UIServerConfigurationSection, +} from '@/types' +import { + OCPP16AvailabilityType, + OCPP16ChargePointStatus, + OCPP16RegistrationStatus, +} from '@/types/ChargingStationType' + +// ── Shared Test Constants ───────────────────────────────────────────────────── + +export const TEST_HASH_ID = 'test-hash-id-abc123' +export const TEST_ID_TAG = 'RFID-TAG-001' +export const TEST_STATION_ID = 'CS-TEST-001' +export const TEST_WS_URL = 'ws://localhost:8080' + +// ── Factory Functions ───────────────────────────────────────────────────────── + +/** + * Creates a ChargingStationData fixture with sensible defaults. + * @param overrides - Optional partial overrides for the fixture + * @returns ChargingStationData fixture + */ +export function createChargingStationData ( + overrides?: Partial +): ChargingStationData { + 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 { + 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 { + 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 { + 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 { + return { + host: 'localhost', + port: 8080, + protocol: Protocol.UI, + version: ProtocolVersion['0.0.1'], + ...overrides, + } +} diff --git a/ui/web/tests/unit/helpers.ts b/ui/web/tests/unit/helpers.ts new file mode 100644 index 00000000..0d2be6d9 --- /dev/null +++ b/ui/web/tests/unit/helpers.ts @@ -0,0 +1,167 @@ +/** + * @file Shared test utilities for Vue.js web UI unit tests + * @description MockWebSocket, withSetup composable helper, mock factories. + */ +import { flushPromises } from '@vue/test-utils' +import { vi } from 'vitest' +import { type App, createApp } from 'vue' + +import { ResponseStatus } from '@/types' + +export { flushPromises as flushAllPromises } + +// ── MockUIClient ────────────────────────────────────────────────────────────── + +export interface MockUIClient { + addChargingStations: ReturnType + authorize: ReturnType + closeConnection: ReturnType + deleteChargingStation: ReturnType + listChargingStations: ReturnType + listTemplates: ReturnType + openConnection: ReturnType + registerWSEventListener: ReturnType + setConfiguration: ReturnType + setSupervisionUrl: ReturnType + simulatorState: ReturnType + startAutomaticTransactionGenerator: ReturnType + startChargingStation: ReturnType + startSimulator: ReturnType + startTransaction: ReturnType + stopAutomaticTransactionGenerator: ReturnType + stopChargingStation: ReturnType + stopSimulator: ReturnType + stopTransaction: ReturnType + unregisterWSEventListener: ReturnType +} + +// ── ButtonStub ──────────────────────────────────────────────────────────────── + +/** Functional Button stub that preserves click event propagation. */ +export const ButtonStub = { + emits: ['click'], + template: '', +} + +// ── MockWebSocket ───────────────────────────────────────────────────────────── + +export class MockWebSocket { + static readonly CLOSED = 3 + static readonly CLOSING = 2 + static readonly CONNECTING = 0 + static readonly OPEN = 1 + + addEventListener: ReturnType + close: ReturnType + 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 + send: ReturnType + 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 + 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 ( + composable: () => T, + options?: { provide?: Record } +): [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] +} diff --git a/ui/web/vitest.config.ts b/ui/web/vitest.config.ts index 891e3761..e94dcc54 100644 --- a/ui/web/vitest.config.ts +++ b/ui/web/vitest.config.ts @@ -4,17 +4,38 @@ import { configDefaults, defineConfig } from 'vitest/config' import viteConfig from './vite.config' +const nodeMajor = Number.parseInt(process.versions.node.split('.')[0], 10) + export default mergeConfig( viteConfig, defineConfig({ test: { + clearMocks: true, coverage: { + exclude: [ + 'src/types/**', + 'src/main.ts', + 'src/**/index.ts', + 'src/shims-vue.d.ts', + 'src/assets/**', + 'src/router/index.ts', + ], + include: ['src/**/*.{ts,vue}'], provider: 'v8', reporter: ['text', 'lcov'], + thresholds: { + branches: 85, + functions: 80, + lines: 87, + statements: 87, + }, }, environment: 'jsdom', exclude: [...configDefaults.exclude, 'e2e/*'], + execArgv: nodeMajor >= 25 ? ['--no-webstorage'] : [], + restoreMocks: true, root: fileURLToPath(new URL('./', import.meta.url)), + setupFiles: ['./tests/setup.ts'], }, }) )