--- /dev/null
+/**
+ * @file Tests for StateButton component
+ * @description Unit tests for label switching, active state CSS class, and on/off callback dispatch.
+ */
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+
+import StateButton from '@/components/buttons/StateButton.vue'
+
+import { ButtonActiveStub } from './helpers'
+
+/**
+ * Mount factory — stubs Button with active class support
+ * @param props - Component props
+ * @param props.active - Whether the button is in active state
+ * @param props.off - Callback when toggled off
+ * @param props.offLabel - Label displayed when active
+ * @param props.on - Callback when toggled on
+ * @param props.onLabel - Label displayed when inactive
+ * @returns Mounted wrapper
+ */
+function mountStateButton (props: {
+ active: boolean
+ off?: () => void
+ offLabel: string
+ on?: () => void
+ onLabel: string
+}) {
+ return mount(StateButton, {
+ global: {
+ stubs: {
+ Button: ButtonActiveStub,
+ },
+ },
+ props,
+ })
+}
+
+describe('StateButton', () => {
+ it('should display onLabel when inactive', () => {
+ const wrapper = mountStateButton({
+ active: false,
+ offLabel: 'Stop',
+ onLabel: 'Start',
+ })
+ expect(wrapper.text()).toBe('Start')
+ })
+
+ it('should display offLabel when active', () => {
+ const wrapper = mountStateButton({
+ active: true,
+ offLabel: 'Stop',
+ onLabel: 'Start',
+ })
+ expect(wrapper.text()).toBe('Stop')
+ })
+
+ it('should call on callback when clicked while inactive', async () => {
+ const onFn = vi.fn()
+ const wrapper = mountStateButton({
+ active: false,
+ offLabel: 'Stop',
+ on: onFn,
+ onLabel: 'Start',
+ })
+ await wrapper.find('button').trigger('click')
+ expect(onFn).toHaveBeenCalledOnce()
+ })
+
+ it('should call off callback when clicked while active', async () => {
+ const offFn = vi.fn()
+ const wrapper = mountStateButton({
+ active: true,
+ off: offFn,
+ offLabel: 'Stop',
+ onLabel: 'Start',
+ })
+ await wrapper.find('button').trigger('click')
+ expect(offFn).toHaveBeenCalledOnce()
+ })
+
+ it('should apply button--active class when active', () => {
+ const wrapper = mountStateButton({
+ active: true,
+ offLabel: 'Stop',
+ onLabel: 'Start',
+ })
+ expect(wrapper.find('button').classes()).toContain('button--active')
+ })
+
+ it('should not apply button--active class when inactive', () => {
+ const wrapper = mountStateButton({
+ active: false,
+ offLabel: 'Stop',
+ onLabel: 'Start',
+ })
+ expect(wrapper.find('button').classes()).not.toContain('button--active')
+ })
+})
* @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 { flushPromises } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
convertToBoolean,
randomUUID,
resetToggleButtonState,
setToLocalStorage,
+ useExecuteAction,
validateUUID,
} from '@/composables/Utils'
+import { toastMock } from '../setup'
+
describe('Utils', () => {
describe('convertToBoolean', () => {
it('should return true for boolean true', () => {
})
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)
})
})
})
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()
})
})
+
+ describe('useExecuteAction', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('should call emit and toast.success on action success', async () => {
+ const emit = vi.fn()
+ const executeAction = useExecuteAction(emit)
+
+ executeAction(Promise.resolve(), 'Success message', 'Error message')
+ await flushPromises()
+
+ expect(emit).toHaveBeenCalledWith('need-refresh')
+ expect(toastMock.success).toHaveBeenCalledWith('Success message')
+ })
+
+ it('should call toast.error and console.error on action failure', async () => {
+ const consoleSpy = vi.spyOn(console, 'error')
+ const emit = vi.fn()
+ const executeAction = useExecuteAction(emit)
+
+ executeAction(Promise.reject(new Error('fail')), 'Success', 'Error at action')
+ await flushPromises()
+
+ expect(emit).not.toHaveBeenCalled()
+ expect(toastMock.error).toHaveBeenCalledWith('Error at action')
+ expect(consoleSpy).toHaveBeenCalledWith('Error at action:', expect.any(Error))
+ })
+ })
})
template: '<button @click="$emit(\'click\')"><slot /></button>',
}
+/** Functional Button stub that supports the active prop and CSS class. */
+export const ButtonActiveStub = {
+ props: ['active'],
+ template:
+ '<button :class="[\'button\', { \'button--active\': active }]" type="button"><slot /></button>',
+}
+
// ── StateButtonStub ───────────────────────────────────────────────────────────
/** Functional StateButton stub that renders the active/inactive label and dispatches on/off. */
export const StateButtonStub = {
+ name: 'StateButton',
props: ['active', 'on', 'off', 'onLabel', 'offLabel'],
template: '<button @click="active ? off?.() : on?.()">{{ active ? offLabel : onLabel }}</button>',
}
+// ── ToggleButtonStub ──────────────────────────────────────────────────────────
+
+/** Functional ToggleButton stub that renders slot content and dispatches on/off via props. */
+export const ToggleButtonStub = {
+ name: 'ToggleButton',
+ props: ['id', 'on', 'off', 'status', 'shared'],
+ template: '<button><slot /></button>',
+}
+
// ── MockWebSocket ─────────────────────────────────────────────────────────────
export class MockWebSocket {