382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
/**
|
|
* Tests for ExposeSettings (M5-T12).
|
|
*
|
|
* Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled
|
|
* without a real server or MQTT broker.
|
|
*
|
|
* Coverage:
|
|
* 1. Loading state shows a loader.
|
|
* 2. Error state shows an error alert.
|
|
* 3. Empty catalog shows the empty-state message.
|
|
* 4. Catalog with entities: renders entity names, device groups, toggle switches.
|
|
* 5. MQTT status badges render correctly (configured/connected/discovery).
|
|
* 6. Toggling a switch calls PUT /api/expose with the correct key/bool.
|
|
* 7. Re-publish button calls POST /api/expose/republish.
|
|
* 8. Republish ok=true shows success alert; ok=false shows warning.
|
|
* 9. value_getter is never rendered (no crash from non-serialisable callable).
|
|
* 10. binary_sensor entities are shown in the catalog alongside sensors.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
|
import { renderWithProviders } from '../test-utils'
|
|
import { ExposeSettings } from './ExposeSettings'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOCK_MQTT_STATUS_OFF = {
|
|
mqtt_configured: false,
|
|
mqtt_connected: false,
|
|
discovery_enabled: false,
|
|
}
|
|
|
|
const MOCK_MQTT_STATUS_ON = {
|
|
mqtt_configured: true,
|
|
mqtt_connected: true,
|
|
discovery_enabled: true,
|
|
}
|
|
|
|
const MOCK_CATALOG_EMPTY = {
|
|
catalog: [],
|
|
mqtt_status: MOCK_MQTT_STATUS_OFF,
|
|
}
|
|
|
|
const MOCK_CATALOG_ONE_DEVICE = {
|
|
catalog: [
|
|
{
|
|
entity: {
|
|
key: 'modbus.abc.voltage',
|
|
component: 'sensor',
|
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
|
device_class: 'voltage',
|
|
unit: 'V',
|
|
name: 'Test Meter Voltage',
|
|
state_class: 'measurement',
|
|
},
|
|
enabled: false,
|
|
},
|
|
{
|
|
entity: {
|
|
key: 'modbus.abc.current',
|
|
component: 'sensor',
|
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
|
device_class: 'current',
|
|
unit: 'A',
|
|
name: 'Test Meter Current',
|
|
state_class: 'measurement',
|
|
},
|
|
enabled: true,
|
|
},
|
|
{
|
|
entity: {
|
|
key: 'modbus.abc.online',
|
|
component: 'binary_sensor',
|
|
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
|
device_class: 'connectivity',
|
|
unit: '',
|
|
name: 'Test Meter Online',
|
|
state_class: null,
|
|
},
|
|
enabled: false,
|
|
},
|
|
],
|
|
mqtt_status: MOCK_MQTT_STATUS_ON,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock apiClient
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mockGet = vi.fn()
|
|
const mockPut = vi.fn()
|
|
const mockPost = vi.fn()
|
|
|
|
vi.mock('../api/client', () => ({
|
|
default: {
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
|
PUT: (...args: unknown[]) => mockPut(...args),
|
|
POST: (...args: unknown[]) => mockPost(...args),
|
|
},
|
|
ApiError: class ApiError extends Error {
|
|
status: number
|
|
body: unknown
|
|
constructor(status: number, body: unknown) {
|
|
super(`API error ${status}`)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
this.body = body
|
|
}
|
|
},
|
|
registerLoginRedirect: vi.fn(),
|
|
}))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderExpose() {
|
|
return renderWithProviders(<ExposeSettings />, { initialPath: '/config' })
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ExposeSettings', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 1. Loading state
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows a loader while the catalog is loading', () => {
|
|
// Never resolves
|
|
mockGet.mockReturnValue(new Promise(() => {}))
|
|
renderExpose()
|
|
expect(screen.getByTestId('expose-loading')).toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 2. Error state
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows an error alert when the catalog request fails', async () => {
|
|
mockGet.mockRejectedValue(new Error('Network error'))
|
|
renderExpose()
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('expose-load-error')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 3. Empty catalog
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows empty-state message when catalog is empty', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
|
renderExpose()
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('expose-empty')).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 4. Catalog with entities
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders entity names and device groups', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
|
})
|
|
|
|
// Device group heading
|
|
expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument()
|
|
|
|
// Entity names
|
|
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
|
expect(screen.getByText('Test Meter Current')).toBeInTheDocument()
|
|
expect(screen.getByText('Test Meter Online')).toBeInTheDocument()
|
|
})
|
|
|
|
it('renders toggle switches for each entity', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
|
})
|
|
|
|
// voltage is disabled (enabled=false in fixture)
|
|
const voltageSwitch = screen.getByTestId(
|
|
'entity-toggle-modbus.abc.voltage',
|
|
) as HTMLInputElement
|
|
expect(voltageSwitch.checked).toBe(false)
|
|
|
|
// current is enabled (enabled=true in fixture)
|
|
const currentSwitch = screen.getByTestId(
|
|
'entity-toggle-modbus.abc.current',
|
|
) as HTMLInputElement
|
|
expect(currentSwitch.checked).toBe(true)
|
|
})
|
|
|
|
it('shows binary_sensor entities alongside sensor entities', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument()
|
|
})
|
|
|
|
// binary_sensor row contains "binary_sensor" text
|
|
const onlineRow = screen.getByTestId('entity-row-modbus.abc.online')
|
|
expect(onlineRow).toHaveTextContent('binary_sensor')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. MQTT status badges
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders MQTT status badges', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
|
expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument()
|
|
expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument()
|
|
})
|
|
|
|
it('shows "Configured" when mqtt_configured is true', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured')
|
|
})
|
|
|
|
it('shows "Not Configured" when mqtt_configured is false', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
|
})
|
|
|
|
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 6. Toggle a switch → PUT /api/expose
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('calls PUT /api/expose when a toggle is switched', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
|
})
|
|
|
|
// Click the voltage toggle (currently off → turning on)
|
|
const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage')
|
|
fireEvent.click(voltageSwitch)
|
|
|
|
await waitFor(() => {
|
|
expect(mockPut).toHaveBeenCalled()
|
|
})
|
|
|
|
const putCall = mockPut.mock.calls[0]
|
|
// path arg
|
|
expect(putCall[0]).toBe('/api/expose')
|
|
// body
|
|
expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true })
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 7. Republish button → POST /api/expose/republish
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('calls POST /api/expose/republish when republish button is clicked', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } })
|
|
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('republish-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(mockPost).toHaveBeenCalled()
|
|
})
|
|
|
|
const postCall = mockPost.mock.calls[0]
|
|
expect(postCall[0]).toBe('/api/expose/republish')
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 8. Republish ok=true → success alert; ok=false → warning
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('shows success alert when republish returns ok=true', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
mockPost.mockResolvedValue({
|
|
data: { ok: true, message: 'HA Discovery re-published successfully.' },
|
|
})
|
|
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('republish-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('republish-success')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('shows warning alert when republish returns ok=false', async () => {
|
|
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
|
mockPost.mockResolvedValue({
|
|
data: { ok: false, message: 'MQTT not connected.' },
|
|
})
|
|
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
|
})
|
|
|
|
fireEvent.click(screen.getByTestId('republish-button'))
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('republish-warning')).toBeInTheDocument()
|
|
})
|
|
expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 9. No crash from non-serialisable value_getter (value_getter NOT rendered)
|
|
// -------------------------------------------------------------------------
|
|
|
|
it('renders without crashing even if value_getter were present (it is excluded by API)', async () => {
|
|
// The API never sends value_getter; this tests resilience of the schema
|
|
const catalogWithExtraField = {
|
|
...MOCK_CATALOG_ONE_DEVICE,
|
|
catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({
|
|
...entry,
|
|
entity: { ...entry.entity },
|
|
// No value_getter — confirm the component handles normal data
|
|
})),
|
|
}
|
|
mockGet.mockResolvedValue({ data: catalogWithExtraField })
|
|
renderExpose()
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
|
})
|
|
// No errors thrown, entities visible
|
|
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
|
})
|
|
})
|