| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731 |
- import { screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { beforeEach, describe, expect, it, vi } from 'vitest';
- import { LocationHASensorModal } from '../../components/LocationHASensorModal';
- import { api } from '../../api/client';
- import { render } from '../utils';
- vi.mock('../../api/client', async () => {
- const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
- return {
- ...actual,
- api: {
- ...actual.api,
- getSettings: vi.fn(),
- getBindableLocationHAEntities: vi.fn(),
- getLocationHASensors: vi.fn(),
- createLocationHASensor: vi.fn(),
- updateLocationHASensor: vi.fn(),
- },
- };
- });
- const getSettings = vi.mocked(api.getSettings);
- const getEntities = vi.mocked(api.getBindableLocationHAEntities);
- const getLocationSensors = vi.mocked(api.getLocationHASensors);
- const createSensor = vi.mocked(api.createLocationHASensor);
- const updateSensor = vi.mocked(api.updateLocationHASensor);
- const LOCATIONS = [{ id: 7, name: 'Drybox 1' }] as never;
- function settings(overrides = {}) {
- return {
- ha_enabled: true,
- ha_url: 'http://homeassistant.local:8123',
- ha_token: 'token',
- ...overrides,
- } as never;
- }
- describe('LocationHASensorModal', () => {
- beforeEach(() => {
- getSettings.mockReset();
- getEntities.mockReset();
- getEntities.mockResolvedValue([]);
- getLocationSensors.mockReset();
- getLocationSensors.mockResolvedValue([]);
- createSensor.mockReset();
- createSensor.mockResolvedValue({} as never);
- updateSensor.mockReset();
- updateSensor.mockResolvedValue({} as never);
- vi.mocked(window.localStorage.getItem).mockReset();
- });
- it('warns when Home Assistant is not configured at all', async () => {
- getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' }));
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- expect(
- await screen.findByText(/Home Assistant is not configured/)
- ).toBeInTheDocument();
- expect(screen.getByText('Settings → Network → Home Assistant')).toBeInTheDocument();
- });
- it('warns when the integration is configured but switched off', async () => {
- getSettings.mockResolvedValue(settings({ ha_enabled: false }));
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- expect(await screen.findByText(/Home Assistant is not configured/)).toBeInTheDocument();
- });
- it('does not ask Home Assistant for entities it cannot reach', async () => {
- getSettings.mockResolvedValue(settings({ ha_token: '' }));
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await screen.findByText(/Home Assistant is not configured/);
- expect(getEntities).not.toHaveBeenCalled();
- });
- it('blocks saving a new sensor while unconfigured', async () => {
- getSettings.mockResolvedValue(settings({ ha_enabled: false }));
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await screen.findByText(/Home Assistant is not configured/);
- expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
- });
- it('shows no warning once Home Assistant is configured', async () => {
- getSettings.mockResolvedValue(settings());
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await waitFor(() => expect(getEntities).toHaveBeenCalled());
- expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
- });
- it('only offers temperature, humidity, and battery entities, not other device classes', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
- { entity_id: 'binary_sensor.drybox_1_door', friendly_name: 'Drybox 1 Door', domain: 'binary_sensor', device_class: 'door', unit_of_measurement: null, state: 'off' },
- ] as never);
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- expect(await screen.findByText('Drybox 1 Temp')).toBeInTheDocument();
- expect(screen.getByText('Drybox 1 Humidity')).toBeInTheDocument();
- expect(screen.getByText('Drybox 1 Battery')).toBeInTheDocument();
- expect(screen.queryByText('Drybox 1 Door')).not.toBeInTheDocument();
- });
- it('only offers a "below" alert threshold for battery sensors, not "above"', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Battery'));
- expect(screen.queryByText(/^Above/)).not.toBeInTheDocument();
- expect(screen.getByText(/^Below/)).toBeInTheDocument();
- expect(screen.getAllByRole('spinbutton')).toHaveLength(1);
- });
- it('prefills the name field from the entity\'s friendly name, not the raw entity id', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
- });
- it('follows the name field to the newly picked entity, as long as it was not typed by hand', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
- await user.click(screen.getByText('Drybox 2 Temp'));
- expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 2 Temp');
- });
- it('does not overwrite a hand-typed name when a different entity is picked', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- const nameInput = screen.getByLabelText(/name/i);
- await user.clear(nameInput);
- await user.type(nameInput, 'My Custom Name');
- await user.click(screen.getByText('Drybox 2 Temp'));
- expect(screen.getByLabelText(/name/i)).toHaveValue('My Custom Name');
- });
- it('saves the friendly name, not the raw entity id', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
- expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ name: 'Drybox 1 Temp' }));
- });
- it('slices an oversized unit to the column width, like the name', async () => {
- // The unit is snapshotted from Home Assistant, not typed by the user —
- // an entity reporting a unit longer than the String(16) column must not
- // come back as a 422 on a field the form never showed.
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: 'degrees Celsius (integrated)', state: '21.0' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
- expect(createSensor).toHaveBeenCalledWith(
- expect.objectContaining({ unit: 'degrees Celsius (integrated)'.slice(0, 16) })
- );
- });
- it('requires a name before saving', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp'));
- await user.clear(screen.getByLabelText(/name/i));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Enter a display name')).toBeInTheDocument();
- expect(createSensor).not.toHaveBeenCalled();
- });
- it('clears a stale "above" value when saving an existing battery sensor', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([]);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(
- <LocationHASensorModal
- sensor={
- {
- id: 1,
- location_id: 7,
- name: 'sensor.drybox_1_battery',
- entity_id: 'sensor.drybox_1_battery',
- kind: 'numeric',
- device_class: 'battery',
- unit: '%',
- alert_state: null,
- alert_above: 95,
- alert_below: 15,
- notify_on_alert: false,
- show_on_card: true,
- sort_order: 0,
- last_state: null,
- last_changed: null,
- last_checked: null,
- created_at: '',
- updated_at: '',
- } as never
- }
- locations={LOCATIONS}
- onClose={() => {}}
- />
- );
- await user.click(await screen.findByRole('button', { name: /save/i }));
- await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
- expect(updateSensor).toHaveBeenCalledWith(1, expect.objectContaining({ alert_above: null, alert_below: 15 }));
- });
- it('shows the location field only when creating a new sensor', async () => {
- getSettings.mockResolvedValue(settings());
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- expect(await screen.findByText('Drybox 1')).toBeInTheDocument();
- });
- it('asks to replace the existing sensor when adding a second temperature sensor', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- {
- entity_id: 'sensor.drybox_1_temp_2',
- friendly_name: 'Drybox 1 Temp 2',
- domain: 'sensor',
- device_class: 'temperature',
- unit_of_measurement: '°C',
- state: '21.0',
- },
- ] as never);
- getLocationSensors.mockResolvedValue([
- {
- id: 1,
- location_id: 7,
- name: 'sensor.drybox_1_temp',
- entity_id: 'sensor.drybox_1_temp',
- kind: 'numeric',
- device_class: 'temperature',
- unit: '°C',
- alert_state: null,
- alert_above: null,
- alert_below: null,
- notify_on_alert: false,
- show_on_card: true,
- sort_order: 0,
- last_state: null,
- last_changed: null,
- last_checked: null,
- created_at: '',
- updated_at: '',
- },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Temp 2'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
- expect(
- screen.getByText(/already has a temperature sensor bound: sensor\.drybox_1_temp\./)
- ).toBeInTheDocument();
- });
- it('asks to replace the existing sensor when adding a second humidity sensor', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- {
- entity_id: 'sensor.drybox_1_humidity_2',
- friendly_name: 'Drybox 1 Humidity 2',
- domain: 'sensor',
- device_class: 'humidity',
- unit_of_measurement: '%',
- state: '45',
- },
- ] as never);
- getLocationSensors.mockResolvedValue([
- {
- id: 2,
- location_id: 7,
- name: 'sensor.drybox_1_humidity',
- entity_id: 'sensor.drybox_1_humidity',
- kind: 'numeric',
- device_class: 'humidity',
- unit: '%',
- alert_state: null,
- alert_above: null,
- alert_below: null,
- notify_on_alert: false,
- show_on_card: true,
- sort_order: 0,
- last_state: null,
- last_changed: null,
- last_checked: null,
- created_at: '',
- updated_at: '',
- },
- ] as never);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity 2'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
- expect(
- screen.getByText(/already has a humidity sensor bound: sensor\.drybox_1_humidity\./)
- ).toBeInTheDocument();
- });
- it('offers to bind sibling entities when adding the first sensor for a location', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- // Oversized unit: the auto-add path must slice it to the String(16)
- // column just like the primary save does.
- { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: 'degrees Celsius (integrated)', state: '21.0' },
- { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
- const temperatureCheckbox = screen.getByRole('checkbox', { name: /drybox 1 temperature/i });
- const batteryCheckbox = screen.getByRole('checkbox', { name: /drybox 1 battery/i });
- expect(temperatureCheckbox).toBeChecked();
- expect(batteryCheckbox).toBeChecked();
- await user.click(screen.getByRole('button', { name: /confirm/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
- expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
- expect(createSensor).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- entity_id: 'sensor.drybox_1_temperature',
- unit: 'degrees Celsius (integrated)'.slice(0, 16),
- })
- );
- expect(createSensor).toHaveBeenNthCalledWith(3, expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
- });
- it('only adds sibling entities left checked in the auto-add dialog', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
- await user.click(screen.getByRole('checkbox', { name: /drybox 1 battery/i }));
- await user.click(screen.getByRole('button', { name: /confirm/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(2));
- expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
- expect(createSensor).toHaveBeenNthCalledWith(2, expect.objectContaining({ entity_id: 'sensor.drybox_1_temperature' }));
- expect(createSensor).not.toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
- });
- it('declining the auto-add prompt still saves the primary sensor, and says so on the button', async () => {
- // "Cancel" here doesn't abandon the save — it saves the sensor the user
- // picked and only skips the siblings — so the button must say that
- // rather than the default "Cancel", which would read as discarding
- // everything.
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- const title = await screen.findByText('Add the other sensors too?');
- const confirmDialog = within(title.closest('.bg-bambu-dark-secondary') as HTMLElement);
- expect(confirmDialog.queryByRole('button', { name: /^cancel$/i })).not.toBeInTheDocument();
- await user.click(confirmDialog.getByRole('button', { name: /only this one/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
- expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
- });
- it('applies the saved per-category defaults to auto-added sibling sensors', async () => {
- vi.mocked(window.localStorage.getItem).mockReturnValue(
- JSON.stringify({ temperature: true, battery: false, humidity: true })
- );
- getSettings.mockResolvedValue(
- settings({
- location_sensor_alert_defaults: JSON.stringify({
- temperature: { alertAbove: '30', alertBelow: '', notifyOnAlert: true },
- battery: { alertAbove: '', alertBelow: '15', notifyOnAlert: true },
- humidity: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
- }),
- })
- );
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
- { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- await screen.findByText('Add the other sensors too?');
- await user.click(screen.getByRole('button', { name: /confirm/i }));
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
- expect(createSensor).toHaveBeenNthCalledWith(
- 2,
- expect.objectContaining({
- entity_id: 'sensor.drybox_1_temperature',
- alert_above: 30,
- alert_below: null,
- notify_on_alert: true,
- show_on_card: true,
- })
- );
- expect(createSensor).toHaveBeenNthCalledWith(
- 3,
- expect.objectContaining({
- entity_id: 'sensor.drybox_1_battery',
- alert_above: null,
- alert_below: 15,
- notify_on_alert: true,
- show_on_card: false,
- })
- );
- });
- it('prefills the form from saved category defaults when picking an entity while creating', async () => {
- // Alert thresholds come from the server setting; only show-on-card is
- // still per-browser (#2824 review round 4).
- vi.mocked(window.localStorage.getItem).mockReturnValue(JSON.stringify({ humidity: false }));
- getSettings.mockResolvedValue(
- settings({
- location_sensor_alert_defaults: JSON.stringify({
- humidity: { alertAbove: '70', alertBelow: '20', notifyOnAlert: true },
- }),
- })
- );
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- const [aboveInput, belowInput] = screen.getAllByRole('spinbutton');
- expect(aboveInput).toHaveValue(70);
- expect(belowInput).toHaveValue(20);
- expect(screen.getByRole('checkbox', { name: /show on filament card/i })).not.toBeChecked();
- expect(screen.getByRole('checkbox', { name: /send a notification/i })).toBeChecked();
- });
- it('does not overwrite an edited sensor with saved defaults when re-selecting its entity', async () => {
- vi.mocked(window.localStorage.getItem).mockReturnValue(
- JSON.stringify({
- temperature: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
- humidity: { alertAbove: '999', alertBelow: '111', notifyOnAlert: true, showOnCard: false },
- battery: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
- })
- );
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(
- <LocationHASensorModal
- sensor={
- {
- id: 1,
- location_id: 7,
- name: 'sensor.drybox_1_humidity',
- entity_id: 'sensor.drybox_1_humidity',
- kind: 'numeric',
- device_class: 'humidity',
- unit: '%',
- alert_state: null,
- alert_above: 55,
- alert_below: null,
- notify_on_alert: false,
- show_on_card: true,
- sort_order: 0,
- last_state: null,
- last_changed: null,
- last_checked: null,
- created_at: '',
- updated_at: '',
- } as never
- }
- locations={LOCATIONS}
- onClose={() => {}}
- />
- );
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- const [aboveInput] = screen.getAllByRole('spinbutton');
- expect(aboveInput).toHaveValue(55);
- });
- it('says so when no sibling entities are found for the first sensor of a location', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
- ] as never);
- getLocationSensors.mockResolvedValue([]);
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
- await user.click(await screen.findByText('Drybox 1 Humidity'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(screen.queryByText('Add the other sensors too?')).not.toBeInTheDocument();
- expect(
- await screen.findByText(/No matching temperature, humidity, or battery sensors found/)
- ).toBeInTheDocument();
- await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
- });
- });
- describe('LocationHASensorModal — overwrite path (#2824)', () => {
- beforeEach(() => {
- getSettings.mockReset();
- getEntities.mockReset();
- getEntities.mockResolvedValue([]);
- getLocationSensors.mockReset();
- getLocationSensors.mockResolvedValue([]);
- createSensor.mockReset();
- createSensor.mockResolvedValue({} as never);
- updateSensor.mockReset();
- updateSensor.mockResolvedValue({} as never);
- vi.mocked(window.localStorage.getItem).mockReset();
- });
- const existingSensor = {
- id: 42,
- location_id: 7,
- name: 'Old Drybox Temp',
- entity_id: 'sensor.drybox_1_temp_old',
- kind: 'numeric',
- device_class: 'temperature',
- unit: '°C',
- alert_state: null,
- alert_above: null,
- alert_below: null,
- notify_on_alert: false,
- show_on_card: true,
- };
- it('PATCHes the existing sensor onto the new entity instead of deleting and recreating', async () => {
- // Regression: delete-then-create left a window where, if the create
- // failed after the delete succeeded, the location's binding was gone
- // with nothing in its place. A single PATCH avoids that window, and
- // this also proves nothing calls deleteLocationHASensor on this path.
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- {
- entity_id: 'sensor.drybox_1_temp_new',
- friendly_name: 'Drybox 1 Temp (new)',
- domain: 'sensor',
- device_class: 'temperature',
- unit_of_measurement: '°C',
- state: '22.0',
- },
- ] as never);
- getLocationSensors.mockResolvedValue([existingSensor] as never);
- const onClose = vi.fn();
- const user = userEvent.setup();
- render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
- await user.click(await screen.findByText('Drybox 1 Temp (new)'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
- await user.click(screen.getByRole('button', { name: 'Confirm' }));
- await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
- expect(updateSensor).toHaveBeenCalledWith(
- 42,
- expect.objectContaining({ entity_id: 'sensor.drybox_1_temp_new', name: 'Drybox 1 Temp (new)' })
- );
- expect(createSensor).not.toHaveBeenCalled();
- expect(onClose).toHaveBeenCalled();
- });
- it('does not close on Escape or backdrop click while the overwrite PATCH is in flight', async () => {
- getSettings.mockResolvedValue(settings());
- getEntities.mockResolvedValue([
- {
- entity_id: 'sensor.drybox_1_temp_new',
- friendly_name: 'Drybox 1 Temp (new)',
- domain: 'sensor',
- device_class: 'temperature',
- unit_of_measurement: '°C',
- state: '22.0',
- },
- ] as never);
- getLocationSensors.mockResolvedValue([existingSensor] as never);
- let resolveUpdate: (() => void) | undefined;
- updateSensor.mockImplementation(
- () =>
- new Promise((resolve) => {
- resolveUpdate = () => resolve({} as never);
- })
- );
- const onClose = vi.fn();
- const user = userEvent.setup();
- const { container } = render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
- await user.click(await screen.findByText('Drybox 1 Temp (new)'));
- await user.click(screen.getByRole('button', { name: /save/i }));
- await screen.findByText('Replace existing sensor?');
- await user.click(screen.getByRole('button', { name: 'Confirm' }));
- await waitFor(() => expect(updateSensor).toHaveBeenCalled());
- await user.keyboard('{Escape}');
- const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/70');
- if (backdrop) await user.click(backdrop);
- expect(onClose).not.toHaveBeenCalled();
- resolveUpdate?.();
- await waitFor(() => expect(onClose).toHaveBeenCalled());
- });
- });
|