]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(webui): add StateButton, centralize active style, fix refresh lifecycle
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 25 Mar 2026 20:30:03 +0000 (21:30 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 25 Mar 2026 20:30:03 +0000 (21:30 +0100)
- StateButton: stateless toggle driven by server state (active prop)
- Button: owns .button--active CSS, ToggleButton/StateButton pass :active
- Conditional buttons: show Start/Stop, Open/Close, Lock/Unlock based on state
- refreshChargingStations(): shared composable eliminates 4x duplicated refresh
- executeAction() helper in CSData/CSConnector: DRY action/toast/refresh
- Fix: trigger getChargingStations() on need-refresh event
- Fix: refresh after AddChargingStations, StartTransaction, SetSupervisionUrl
- table-layout: fixed on connectors table to prevent overflow

16 files changed:
ui/web/src/components/actions/AddChargingStations.vue
ui/web/src/components/actions/SetSupervisionUrl.vue
ui/web/src/components/actions/StartTransaction.vue
ui/web/src/components/buttons/Button.vue
ui/web/src/components/buttons/StateButton.vue [new file with mode: 0644]
ui/web/src/components/buttons/ToggleButton.vue
ui/web/src/components/charging-stations/CSConnector.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/Utils.ts
ui/web/src/composables/index.ts
ui/web/src/views/ChargingStationsView.vue
ui/web/tests/unit/CSConnector.test.ts
ui/web/tests/unit/CSData.test.ts
ui/web/tests/unit/ChargingStationsView.test.ts
ui/web/tests/unit/ToggleButton.test.ts
ui/web/tests/unit/helpers.ts

index 0a8745996b20512636c460843659844990d8cfa9..83de85296754bcfa2265655f88a55eda29a4185f 100644 (file)
@@ -96,6 +96,7 @@
           })
           .then(() => {
             $toast.success('Charging stations successfully added')
+            return refreshChargingStations()
           })
           .catch((error: Error) => {
             $toast.error('Error at adding charging stations')
@@ -118,7 +119,12 @@ import { getCurrentInstance, ref, watch } from 'vue'
 import type { UUIDv4 } from '@/types'
 
 import Button from '@/components/buttons/Button.vue'
-import { convertToBoolean, randomUUID, resetToggleButtonState } from '@/composables'
+import {
+  convertToBoolean,
+  randomUUID,
+  refreshChargingStations,
+  resetToggleButtonState,
+} from '@/composables'
 
 const state = ref<{
   autoStart: boolean
@@ -140,7 +146,9 @@ const state = ref<{
   template: '',
 })
 
-const templates = getCurrentInstance()?.appContext.config.globalProperties.$templates
+const app = getCurrentInstance()
+
+const templates = app?.appContext.config.globalProperties.$templates
 if (templates != null) {
   watch(templates, () => {
     state.value.renderTemplates = randomUUID()
index 4cbbd905fd9b8e1042856e2f685fbfd6dbefd9ac..6406467f953e9d2d451cd4577fdf2544e3ce6199 100644 (file)
@@ -21,6 +21,7 @@
           ?.setSupervisionUrl(hashId, state.supervisionUrl)
           .then(() => {
             $toast.success('Supervision url successfully set')
+            return refreshChargingStations()
           })
           .catch((error: Error) => {
             $toast.error('Error at setting supervision url')
@@ -41,7 +42,7 @@
 import { ref } from 'vue'
 
 import Button from '@/components/buttons/Button.vue'
-import { resetToggleButtonState } from '@/composables'
+import { refreshChargingStations, resetToggleButtonState } from '@/composables'
 
 const props = defineProps<{
   chargingStationId: string
index ac7f92309ea7ddac44dafb14766fe7df76822a9e..973c0426a21a38cf9a8e59e6ef381f0b8dc50269 100644 (file)
@@ -42,7 +42,13 @@ import { useRoute, useRouter } from 'vue-router'
 import { useToast } from 'vue-toast-notification'
 
 import Button from '@/components/buttons/Button.vue'
-import { convertToInt, resetToggleButtonState, UIClient, useUIClient } from '@/composables'
+import {
+  convertToInt,
+  refreshChargingStations,
+  resetToggleButtonState,
+  UIClient,
+  useUIClient,
+} from '@/composables'
 import { type OCPPVersion } from '@/types'
 
 const props = defineProps<{
@@ -99,6 +105,7 @@ const handleStartTransaction = async (): Promise<void> => {
       ocppVersion: ocppVersion.value,
     })
     $toast.success('Transaction successfully started')
+    await refreshChargingStations()
   } catch (error) {
     $toast.error('Error at starting transaction')
     console.error('Error at starting transaction:', error)
index 8920855e220658a8207846621bdb9fd747b58060..2b2f8c91c6acf0aaf3c1598c33a1fb653b23a571 100644 (file)
@@ -1,12 +1,23 @@
 <template>
   <button
-    class="button"
+    :class="['button', { 'button--active': active }]"
     type="button"
   >
     <slot />
   </button>
 </template>
 
+<script setup lang="ts">
+withDefaults(
+  defineProps<{
+    active?: boolean
+  }>(),
+  {
+    active: false,
+  }
+)
+</script>
+
 <style scoped>
 .button {
   display: block;
   outline: 2px solid var(--color-accent);
   outline-offset: -2px;
 }
+
+.button--active {
+  color: var(--color-text);
+  background-color: var(--color-bg-active);
+  border: 1px solid var(--color-accent);
+  box-shadow: inset 0 2px 4px var(--color-shadow-inset);
+}
 </style>
diff --git a/ui/web/src/components/buttons/StateButton.vue b/ui/web/src/components/buttons/StateButton.vue
new file mode 100644 (file)
index 0000000..f3fd2d1
--- /dev/null
@@ -0,0 +1,20 @@
+<template>
+  <Button
+    :active="active"
+    @click="active ? off?.() : on?.()"
+  >
+    {{ active ? offLabel : onLabel }}
+  </Button>
+</template>
+
+<script setup lang="ts">
+import Button from '@/components/buttons/Button.vue'
+
+defineProps<{
+  active: boolean
+  off?: () => void
+  offLabel: string
+  on?: () => void
+  onLabel: string
+}>()
+</script>
index 694d7502f3213346056864e0e551d75aad8ac129..b56319751eb31a158174cad6aeab0f34953d9821 100644 (file)
@@ -1,6 +1,6 @@
 <template>
   <Button
-    :class="{ on: state.status }"
+    :active="state.status"
     @click="click()"
   >
     <slot />
@@ -48,12 +48,3 @@ const click = (): void => {
   $emit('clicked', getFromLocalStorage<boolean>(id, props.status ?? false))
 }
 </script>
-
-<style scoped>
-button.on {
-  color: var(--color-text);
-  background-color: var(--color-bg-active);
-  border: 1px solid var(--color-accent);
-  box-shadow: inset 0 2px 4px var(--color-shadow-inset);
-}
-</style>
index c1dbddc973f6ab25d2fa276ebdde16ac8a465296..661ba834d984771d3fe1bcd214ee80ccb5097edc 100644 (file)
       {{ atgStatus?.start === true ? 'Yes' : 'No' }}
     </td>
     <td class="connectors-table__column">
+      <StateButton
+        :active="connector.locked === true"
+        :off="() => unlockConnector()"
+        off-label="Unlock"
+        :on="() => lockConnector()"
+        on-label="Lock"
+      />
       <ToggleButton
+        v-if="connector.transactionStarted !== true"
         :id="`${hashId}-${evseId ?? 0}-${connectorId}-start-transaction`"
         :off="
           () => {
       >
         Start Transaction
       </ToggleButton>
-      <Button @click="stopTransaction()">
-        Stop Transaction
-      </Button>
-      <Button @click="startAutomaticTransactionGenerator()">
-        Start ATG
-      </Button>
-      <Button @click="stopAutomaticTransactionGenerator()">
-        Stop ATG
-      </Button>
-      <Button
-        v-if="connector.locked !== true"
-        @click="lockConnector()"
-      >
-        Lock
-      </Button>
       <Button
         v-else
-        @click="unlockConnector()"
+        @click="stopTransaction()"
       >
-        Unlock
+        Stop Transaction
       </Button>
+      <StateButton
+        :active="atgStatus?.start === true"
+        :off="() => stopAutomaticTransactionGenerator()"
+        off-label="Stop ATG"
+        :on="() => startAutomaticTransactionGenerator()"
+        on-label="Start ATG"
+      />
     </td>
   </tr>
 </template>
@@ -71,6 +71,7 @@ import { useToast } from 'vue-toast-notification'
 import type { ConnectorStatus, OCPPVersion, Status } from '@/types'
 
 import Button from '@/components/buttons/Button.vue'
+import StateButton from '@/components/buttons/StateButton.vue'
 import ToggleButton from '@/components/buttons/ToggleButton.vue'
 import { useUIClient } from '@/composables'
 
@@ -90,66 +91,58 @@ const uiClient = useUIClient()
 
 const $toast = useToast()
 
+const executeAction = (action: Promise<unknown>, successMsg: string, errorMsg: string): void => {
+  action
+    .then(() => {
+      $emit('need-refresh')
+      return $toast.success(successMsg)
+    })
+    .catch((error: Error) => {
+      $toast.error(errorMsg)
+      console.error(`${errorMsg}:`, error)
+    })
+}
+
 const stopTransaction = (): void => {
   if (props.connector.transactionId == null) {
     $toast.error('No transaction to stop')
     return
   }
-  uiClient
-    .stopTransaction(props.hashId, {
+  executeAction(
+    uiClient.stopTransaction(props.hashId, {
       ocppVersion: props.ocppVersion,
       transactionId: props.connector.transactionId,
-    })
-    .then(() => {
-      return $toast.success('Transaction successfully stopped')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at stopping transaction')
-      console.error('Error at stopping transaction:', error)
-    })
+    }),
+    'Transaction successfully stopped',
+    'Error at stopping transaction'
+  )
 }
 const lockConnector = (): void => {
-  uiClient
-    .lockConnector(props.hashId, props.connectorId)
-    .then(() => {
-      return $toast.success('Connector successfully locked')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at locking connector')
-      console.error('Error at locking connector:', error)
-    })
+  executeAction(
+    uiClient.lockConnector(props.hashId, props.connectorId),
+    'Connector successfully locked',
+    'Error at locking connector'
+  )
 }
 const unlockConnector = (): void => {
-  uiClient
-    .unlockConnector(props.hashId, props.connectorId)
-    .then(() => {
-      return $toast.success('Connector successfully unlocked')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at unlocking connector')
-      console.error('Error at unlocking connector:', error)
-    })
+  executeAction(
+    uiClient.unlockConnector(props.hashId, props.connectorId),
+    'Connector successfully unlocked',
+    'Error at unlocking connector'
+  )
 }
 const startAutomaticTransactionGenerator = (): void => {
-  uiClient
-    .startAutomaticTransactionGenerator(props.hashId, props.connectorId)
-    .then(() => {
-      return $toast.success('Automatic transaction generator successfully started')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at starting automatic transaction generator')
-      console.error('Error at starting automatic transaction generator:', error)
-    })
+  executeAction(
+    uiClient.startAutomaticTransactionGenerator(props.hashId, props.connectorId),
+    'Automatic transaction generator successfully started',
+    'Error at starting automatic transaction generator'
+  )
 }
 const stopAutomaticTransactionGenerator = (): void => {
-  uiClient
-    .stopAutomaticTransactionGenerator(props.hashId, props.connectorId)
-    .then(() => {
-      return $toast.success('Automatic transaction generator successfully stopped')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at stopping automatic transaction generator')
-      console.error('Error at stopping automatic transaction generator:', error)
-    })
+  executeAction(
+    uiClient.stopAutomaticTransactionGenerator(props.hashId, props.connectorId),
+    'Automatic transaction generator successfully stopped',
+    'Error at stopping automatic transaction generator'
+  )
 }
 </script>
index db719e0ee28fc4ff97fa6d6953d7d464a933da4a..417620da850c6a53dfb611c36ec07a3a726a1fdd 100644 (file)
       {{ chargingStation.stationInfo.firmwareVersion ?? 'Ø' }}
     </td>
     <td class="cs-table__column">
-      <Button @click="startChargingStation()">
-        Start Charging Station
-      </Button>
-      <Button @click="stopChargingStation()">
-        Stop Charging Station
-      </Button>
+      <StateButton
+        :active="chargingStation.started === true"
+        :off="() => stopChargingStation()"
+        off-label="Stop Charging Station"
+        :on="() => startChargingStation()"
+        on-label="Start Charging Station"
+      />
+      <StateButton
+        :active="isWebSocketOpen"
+        :off="() => closeConnection()"
+        off-label="Close Connection"
+        :on="() => openConnection()"
+        on-label="Open Connection"
+      />
       <ToggleButton
         :id="`${chargingStation.stationInfo.hashId}-set-supervision-url`"
         :off="
       >
         Set Supervision Url
       </ToggleButton>
-      <Button @click="openConnection()">
-        Open Connection
-      </Button>
-      <Button @click="closeConnection()">
-        Close Connection
-      </Button>
       <Button @click="deleteChargingStation()">
         Delete Charging Station
       </Button>
 </template>
 
 <script setup lang="ts">
+import { computed } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
 import type { ChargingStationData, ConnectorStatus, Status } from '@/types'
 
 import Button from '@/components/buttons/Button.vue'
+import StateButton from '@/components/buttons/StateButton.vue'
 import ToggleButton from '@/components/buttons/ToggleButton.vue'
 import CSConnector from '@/components/charging-stations/CSConnector.vue'
 import { deleteFromLocalStorage, getLocalStorage, useUIClient } from '@/composables'
@@ -153,6 +157,8 @@ const props = defineProps<{
 
 const $emit = defineEmits(['need-refresh'])
 
+const isWebSocketOpen = computed(() => props.chargingStation.wsState === WebSocket.OPEN)
+
 const getConnectorEntries = (): ConnectorTableEntry[] => {
   if (Array.isArray(props.chargingStation.evses) && props.chargingStation.evses.length > 0) {
     const entries: ConnectorTableEntry[] = []
@@ -206,49 +212,45 @@ const uiClient = useUIClient()
 
 const $toast = useToast()
 
-const startChargingStation = (): void => {
-  uiClient
-    .startChargingStation(props.chargingStation.stationInfo.hashId)
+const executeAction = (action: Promise<unknown>, successMsg: string, errorMsg: string): void => {
+  action
     .then(() => {
-      return $toast.success('Charging station successfully started')
+      $emit('need-refresh')
+      return $toast.success(successMsg)
     })
     .catch((error: Error) => {
-      $toast.error('Error at starting charging station')
-      console.error('Error at starting charging station:', error)
+      $toast.error(errorMsg)
+      console.error(`${errorMsg}:`, error)
     })
 }
+
+const startChargingStation = (): void => {
+  executeAction(
+    uiClient.startChargingStation(props.chargingStation.stationInfo.hashId),
+    'Charging station successfully started',
+    'Error at starting charging station'
+  )
+}
 const stopChargingStation = (): void => {
-  uiClient
-    .stopChargingStation(props.chargingStation.stationInfo.hashId)
-    .then(() => {
-      return $toast.success('Charging station successfully stopped')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at stopping charging station')
-      console.error('Error at stopping charging station:', error)
-    })
+  executeAction(
+    uiClient.stopChargingStation(props.chargingStation.stationInfo.hashId),
+    'Charging station successfully stopped',
+    'Error at stopping charging station'
+  )
 }
 const openConnection = (): void => {
-  uiClient
-    .openConnection(props.chargingStation.stationInfo.hashId)
-    .then(() => {
-      return $toast.success('Connection successfully opened')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at opening connection')
-      console.error('Error at opening connection:', error)
-    })
+  executeAction(
+    uiClient.openConnection(props.chargingStation.stationInfo.hashId),
+    'Connection successfully opened',
+    'Error at opening connection'
+  )
 }
 const closeConnection = (): void => {
-  uiClient
-    .closeConnection(props.chargingStation.stationInfo.hashId)
-    .then(() => {
-      return $toast.success('Connection successfully closed')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at closing connection')
-      console.error('Error at closing connection:', error)
-    })
+  executeAction(
+    uiClient.closeConnection(props.chargingStation.stationInfo.hashId),
+    'Connection successfully closed',
+    'Error at closing connection'
+  )
 }
 const deleteChargingStation = (): void => {
   uiClient
@@ -259,6 +261,7 @@ const deleteChargingStation = (): void => {
           deleteFromLocalStorage(key)
         }
       }
+      $emit('need-refresh')
       return $toast.success('Charging station successfully deleted')
     })
     .catch((error: Error) => {
index 824fd3daef3d6a82348a6d2d43358e1623b6e406..1c1613dab4ebc886b8acd48223732c8870b3eeb3 100644 (file)
@@ -1,4 +1,8 @@
-import type { UUIDv4 } from '@/types'
+import type { Ref } from 'vue'
+
+import { getCurrentInstance } from 'vue'
+
+import type { ChargingStationData, UUIDv4 } from '@/types'
 
 import { UIClient } from './UIClient'
 
@@ -82,3 +86,14 @@ export const validateUUID = (uuid: unknown): uuid is UUIDv4 => {
 export const useUIClient = (): UIClient => {
   return UIClient.getInstance()
 }
+
+export const useChargingStations = (): Ref<ChargingStationData[]> | undefined => {
+  return getCurrentInstance()?.appContext.config.globalProperties.$chargingStations
+}
+
+export const refreshChargingStations = async (): Promise<void> => {
+  const ref = useChargingStations()
+  if (ref == null) return
+  const response = await useUIClient().listChargingStations()
+  ref.value = response.chargingStations as ChargingStationData[]
+}
index ab6d17992b43e17ad9bd164aa6a1afa216c8eef3..7095b842f2f5c9c8302c686b35f35b0207f4abd7 100644 (file)
@@ -6,7 +6,9 @@ export {
   getFromLocalStorage,
   getLocalStorage,
   randomUUID,
+  refreshChargingStations,
   resetToggleButtonState,
   setToLocalStorage,
+  useChargingStations,
   useUIClient,
 } from './Utils'
index a74da37ae7d22b35f0125d2bd03d9169f72bbf20..4128c09f58182e431fc91aeaf24f1f40c2d104e8 100644 (file)
           </option>
         </select>
       </Container>
-      <ToggleButton
-        :id="'simulator'"
-        :key="state.renderSimulator"
+      <StateButton
+        :active="simulatorStarted === true"
         :off="() => stopSimulator()"
+        :off-label="simulatorLabel('Stop')"
         :on="() => startSimulator()"
-        :status="simulatorStarted"
-      >
-        {{ simulatorButtonMessage }}
-      </ToggleButton>
+        :on-label="simulatorLabel('Start')"
+      />
       <ToggleButton
         :id="'add-charging-stations'"
         :key="state.renderAddChargingStations"
       :charging-stations="$chargingStations!.value"
       @need-refresh="
         () => {
+          getChargingStations()
           state.renderAddChargingStations = randomUUID()
           state.renderChargingStations = randomUUID()
         }
@@ -127,6 +126,7 @@ import type {
 } from '@/types'
 
 import ReloadButton from '@/components/buttons/ReloadButton.vue'
+import StateButton from '@/components/buttons/StateButton.vue'
 import ToggleButton from '@/components/buttons/ToggleButton.vue'
 import CSTable from '@/components/charging-stations/CSTable.vue'
 import Container from '@/components/Container.vue'
@@ -143,12 +143,10 @@ const simulatorState = ref<SimulatorState | undefined>(undefined)
 
 const simulatorStarted = computed((): boolean | undefined => simulatorState.value?.started)
 
-const simulatorButtonMessage = computed(
-  (): string =>
-    `${simulatorState.value?.started === true ? 'Stop' : 'Start'} Simulator${
-      simulatorState.value?.version != null ? ` (${simulatorState.value.version})` : ''
-    }`
-)
+const simulatorLabel = (action: string): string =>
+  `${action} Simulator${
+    simulatorState.value?.version != null ? ` (${simulatorState.value.version})` : ''
+  }`
 
 const state = ref<{
   gettingChargingStations: boolean
@@ -156,7 +154,6 @@ const state = ref<{
   gettingTemplates: boolean
   renderAddChargingStations: UUIDv4
   renderChargingStations: UUIDv4
-  renderSimulator: UUIDv4
   uiServerIndex: number
 }>({
   gettingChargingStations: false,
@@ -164,7 +161,6 @@ const state = ref<{
   gettingTemplates: false,
   renderAddChargingStations: randomUUID(),
   renderChargingStations: randomUUID(),
-  renderSimulator: randomUUID(),
   uiServerIndex: getFromLocalStorage<number>('uiServerConfigurationIndex', 0),
 })
 
@@ -190,10 +186,6 @@ if (chargingStationsRef != null) {
   })
 }
 
-watch(simulatorState, () => {
-  state.value.renderSimulator = randomUUID()
-})
-
 const clearTemplates = (): void => {
   if (app != null) {
     app.appContext.config.globalProperties.$templates!.value = []
index ba327d3f6bc9f9900436d0242f298a211e74388b..cfc3dca795ca62c71b9f7b5095905fcc6cdc8278 100644 (file)
@@ -13,7 +13,7 @@ import { OCPP16ChargePointStatus } from '@/types'
 
 import { toastMock } from '../setup'
 import { createConnectorStatus, TEST_HASH_ID, TEST_STATION_ID } from './constants'
-import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+import { ButtonStub, createMockUIClient, type MockUIClient, StateButtonStub } from './helpers'
 
 vi.mock('@/composables', async importOriginal => {
   const actual = await importOriginal()
@@ -30,6 +30,7 @@ function mountCSConnector (overrideProps: Record<string, unknown> = {}) {
     global: {
       stubs: {
         Button: ButtonStub,
+        StateButton: StateButtonStub,
         ToggleButton: true,
       },
     },
@@ -128,7 +129,10 @@ describe('CSConnector', () => {
     })
 
     it('should show error toast when no transaction to stop', async () => {
-      const connector = createConnectorStatus({ transactionId: undefined })
+      const connector = createConnectorStatus({
+        transactionId: undefined,
+        transactionStarted: true,
+      })
       const wrapper = mountCSConnector({ connector })
       const buttons = wrapper.findAll('button')
       const stopBtn = buttons.find(b => b.text() === 'Stop Transaction')
@@ -158,7 +162,7 @@ describe('CSConnector', () => {
     })
 
     it('should call stopAutomaticTransactionGenerator', async () => {
-      const wrapper = mountCSConnector()
+      const wrapper = mountCSConnector({ atgStatus: { start: true } })
       const buttons = wrapper.findAll('button')
       const stopAtgBtn = buttons.find(b => b.text() === 'Stop ATG')
       await stopAtgBtn?.trigger('click')
@@ -191,7 +195,7 @@ describe('CSConnector', () => {
 
     it('should show error toast when ATG stop fails', async () => {
       mockClient.stopAutomaticTransactionGenerator.mockRejectedValueOnce(new Error('fail'))
-      const wrapper = mountCSConnector()
+      const wrapper = mountCSConnector({ atgStatus: { start: true } })
       const buttons = wrapper.findAll('button')
       const btn = buttons.find(b => b.text().includes('Stop ATG'))
       await btn?.trigger('click')
index b5bbbe1d74ab15ce81a5d2c27a7f5dad79c064f1..f59974a1a493c9a729e7350a28a3ffd29d155afa 100644 (file)
@@ -20,7 +20,7 @@ import {
   createEvseEntry,
   createStationInfo,
 } from './constants'
-import { ButtonStub, createMockUIClient, type MockUIClient } from './helpers'
+import { ButtonStub, createMockUIClient, type MockUIClient, StateButtonStub } from './helpers'
 
 vi.mock('@/composables', async importOriginal => {
   const actual = await importOriginal()
@@ -38,6 +38,7 @@ function mountCSData (chargingStation: ChargingStationData = createChargingStati
       stubs: {
         Button: ButtonStub,
         CSConnector: true,
+        StateButton: StateButtonStub,
         ToggleButton: true,
       },
     },
@@ -157,7 +158,7 @@ describe('CSData', () => {
 
   describe('station actions', () => {
     it('should call startChargingStation on button click', async () => {
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ started: false }))
       const buttons = wrapper.findAll('button')
       const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
       await startBtn?.trigger('click')
@@ -166,7 +167,7 @@ describe('CSData', () => {
     })
 
     it('should call stopChargingStation on button click', async () => {
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ started: true }))
       const buttons = wrapper.findAll('button')
       const stopBtn = buttons.find(b => b.text() === 'Stop Charging Station')
       await stopBtn?.trigger('click')
@@ -175,7 +176,7 @@ describe('CSData', () => {
     })
 
     it('should call openConnection on button click', async () => {
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.CLOSED }))
       const buttons = wrapper.findAll('button')
       const openBtn = buttons.find(b => b.text() === 'Open Connection')
       await openBtn?.trigger('click')
@@ -184,7 +185,7 @@ describe('CSData', () => {
     })
 
     it('should call closeConnection on button click', async () => {
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ wsState: WebSocket.OPEN }))
       const buttons = wrapper.findAll('button')
       const closeBtn = buttons.find(b => b.text() === 'Close Connection')
       await closeBtn?.trigger('click')
@@ -202,7 +203,7 @@ describe('CSData', () => {
     })
 
     it('should show success toast after starting charging station', async () => {
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ started: false }))
       const buttons = wrapper.findAll('button')
       const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
       await startBtn?.trigger('click')
@@ -212,7 +213,7 @@ describe('CSData', () => {
 
     it('should show error toast on start failure', async () => {
       mockClient.startChargingStation.mockRejectedValueOnce(new Error('fail'))
-      const wrapper = mountCSData()
+      const wrapper = mountCSData(createChargingStationData({ started: false }))
       const buttons = wrapper.findAll('button')
       const startBtn = buttons.find(b => b.text() === 'Start Charging Station')
       await startBtn?.trigger('click')
index 9a682d1bf93abf31e23ae7e4ade0860a8ba7e4e2..d56f241f497fb198710eac2864b54229bca02f69 100644 (file)
@@ -90,6 +90,12 @@ function mountView (
           props: ['loading'],
           template: '<button @click="$emit(\'click\')" />',
         },
+        StateButton: {
+          name: 'StateButton',
+          props: ['active', 'on', 'off', 'onLabel', 'offLabel'],
+          template:
+            '<button @click="active ? off?.() : on?.()">{{ active ? offLabel : onLabel }}</button>',
+        },
         ToggleButton: {
           name: 'ToggleButton',
           props: ['id', 'on', 'off', 'status', 'shared'],
@@ -283,9 +289,8 @@ describe('ChargingStationsView', () => {
         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
+      const stateButton = wrapper.findComponent({ name: 'StateButton' })
+      const onProp = stateButton.props('on') as (() => void) | undefined
       onProp?.()
       await flushPromises()
       expect(mockClient.startSimulator).toHaveBeenCalled()
@@ -295,9 +300,8 @@ describe('ChargingStationsView', () => {
     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
+      const stateButton = wrapper.findComponent({ name: 'StateButton' })
+      const onProp = stateButton.props('on') as (() => void) | undefined
       onProp?.()
       await flushPromises()
       expect(toastMock.error).toHaveBeenCalledWith('Error at starting simulator')
@@ -308,9 +312,8 @@ describe('ChargingStationsView', () => {
         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
+      const stateButton = wrapper.findComponent({ name: 'StateButton' })
+      const offProp = stateButton.props('off') as (() => void) | undefined
       offProp?.()
       await flushPromises()
       expect(mockClient.stopSimulator).toHaveBeenCalled()
@@ -320,9 +323,8 @@ describe('ChargingStationsView', () => {
     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
+      const stateButton = wrapper.findComponent({ name: 'StateButton' })
+      const offProp = stateButton.props('off') as (() => void) | undefined
       offProp?.()
       await flushPromises()
       expect(toastMock.error).toHaveBeenCalledWith('Error at stopping simulator')
index 2314e7e56db5e29d499964258c1f3ccac43d7746..65536aca985316415f144442474a9c2beca9dbfe 100644 (file)
@@ -30,7 +30,9 @@ function mountToggleButton (
     global: {
       stubs: {
         Button: {
-          template: '<button class="button" type="button"><slot /></button>',
+          props: ['active'],
+          template:
+            '<button :class="[\'button\', { \'button--active\': active }]" type="button"><slot /></button>',
         },
       },
     },
@@ -49,13 +51,13 @@ describe('ToggleButton', () => {
     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')
+      expect(button.classes()).not.toContain('button--active')
     })
 
     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')
+      expect(button.classes()).toContain('button--active')
     })
   })
 
@@ -64,18 +66,18 @@ describe('ToggleButton', () => {
       const wrapper = mountToggleButton({ id: 'toggle-inactive-to-active', status: false })
       const button = wrapper.find('button')
 
-      expect(button.classes()).not.toContain('on')
+      expect(button.classes()).not.toContain('button--active')
       await button.trigger('click')
-      expect(button.classes()).toContain('on')
+      expect(button.classes()).toContain('button--active')
     })
 
     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')
+      expect(button.classes()).toContain('button--active')
       await button.trigger('click')
-      expect(button.classes()).not.toContain('on')
+      expect(button.classes()).not.toContain('button--active')
     })
 
     it('should call on callback when toggled to active', async () => {
@@ -137,7 +139,7 @@ describe('ToggleButton', () => {
       const wrapper = mountToggleButton({ id: 'restore-test', status: false })
       const button = wrapper.find('button')
 
-      expect(button.classes()).toContain('on')
+      expect(button.classes()).toContain('button--active')
     })
 
     it('should use correct localStorage key for non-shared toggle', async () => {
@@ -217,7 +219,7 @@ describe('ToggleButton', () => {
       const wrapper = mountToggleButton({ id: 'default-status' })
       const button = wrapper.find('button')
 
-      expect(button.classes()).not.toContain('on')
+      expect(button.classes()).not.toContain('button--active')
     })
 
     it('should handle multiple consecutive clicks', async () => {
@@ -225,13 +227,13 @@ describe('ToggleButton', () => {
       const button = wrapper.find('button')
 
       await button.trigger('click')
-      expect(button.classes()).toContain('on')
+      expect(button.classes()).toContain('button--active')
 
       await button.trigger('click')
-      expect(button.classes()).not.toContain('on')
+      expect(button.classes()).not.toContain('button--active')
 
       await button.trigger('click')
-      expect(button.classes()).toContain('on')
+      expect(button.classes()).toContain('button--active')
     })
   })
 })
index 4fb255caf31c6518c34c4aa53d5b6d87b6f3f9a6..fff69fb3142597f24e8a7cd0f6b2bf6caa0e3e05 100644 (file)
@@ -45,6 +45,14 @@ export const ButtonStub = {
   template: '<button @click="$emit(\'click\')"><slot /></button>',
 }
 
+// ── StateButtonStub ───────────────────────────────────────────────────────────
+
+/** Functional StateButton stub that renders the active/inactive label and dispatches on/off. */
+export const StateButtonStub = {
+  props: ['active', 'on', 'off', 'onLabel', 'offLabel'],
+  template: '<button @click="active ? off?.() : on?.()">{{ active ? offLabel : onLabel }}</button>',
+}
+
 // ── MockWebSocket ─────────────────────────────────────────────────────────────
 
 export class MockWebSocket {