LocationHASensorModal.test.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731
  1. import { screen, waitFor, within } from '@testing-library/react';
  2. import userEvent from '@testing-library/user-event';
  3. import { beforeEach, describe, expect, it, vi } from 'vitest';
  4. import { LocationHASensorModal } from '../../components/LocationHASensorModal';
  5. import { api } from '../../api/client';
  6. import { render } from '../utils';
  7. vi.mock('../../api/client', async () => {
  8. const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
  9. return {
  10. ...actual,
  11. api: {
  12. ...actual.api,
  13. getSettings: vi.fn(),
  14. getBindableLocationHAEntities: vi.fn(),
  15. getLocationHASensors: vi.fn(),
  16. createLocationHASensor: vi.fn(),
  17. updateLocationHASensor: vi.fn(),
  18. },
  19. };
  20. });
  21. const getSettings = vi.mocked(api.getSettings);
  22. const getEntities = vi.mocked(api.getBindableLocationHAEntities);
  23. const getLocationSensors = vi.mocked(api.getLocationHASensors);
  24. const createSensor = vi.mocked(api.createLocationHASensor);
  25. const updateSensor = vi.mocked(api.updateLocationHASensor);
  26. const LOCATIONS = [{ id: 7, name: 'Drybox 1' }] as never;
  27. function settings(overrides = {}) {
  28. return {
  29. ha_enabled: true,
  30. ha_url: 'http://homeassistant.local:8123',
  31. ha_token: 'token',
  32. ...overrides,
  33. } as never;
  34. }
  35. describe('LocationHASensorModal', () => {
  36. beforeEach(() => {
  37. getSettings.mockReset();
  38. getEntities.mockReset();
  39. getEntities.mockResolvedValue([]);
  40. getLocationSensors.mockReset();
  41. getLocationSensors.mockResolvedValue([]);
  42. createSensor.mockReset();
  43. createSensor.mockResolvedValue({} as never);
  44. updateSensor.mockReset();
  45. updateSensor.mockResolvedValue({} as never);
  46. vi.mocked(window.localStorage.getItem).mockReset();
  47. });
  48. it('warns when Home Assistant is not configured at all', async () => {
  49. getSettings.mockResolvedValue(settings({ ha_enabled: false, ha_url: '', ha_token: '' }));
  50. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  51. expect(
  52. await screen.findByText(/Home Assistant is not configured/)
  53. ).toBeInTheDocument();
  54. expect(screen.getByText('Settings → Network → Home Assistant')).toBeInTheDocument();
  55. });
  56. it('warns when the integration is configured but switched off', async () => {
  57. getSettings.mockResolvedValue(settings({ ha_enabled: false }));
  58. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  59. expect(await screen.findByText(/Home Assistant is not configured/)).toBeInTheDocument();
  60. });
  61. it('does not ask Home Assistant for entities it cannot reach', async () => {
  62. getSettings.mockResolvedValue(settings({ ha_token: '' }));
  63. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  64. await screen.findByText(/Home Assistant is not configured/);
  65. expect(getEntities).not.toHaveBeenCalled();
  66. });
  67. it('blocks saving a new sensor while unconfigured', async () => {
  68. getSettings.mockResolvedValue(settings({ ha_enabled: false }));
  69. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  70. await screen.findByText(/Home Assistant is not configured/);
  71. expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
  72. });
  73. it('shows no warning once Home Assistant is configured', async () => {
  74. getSettings.mockResolvedValue(settings());
  75. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  76. await waitFor(() => expect(getEntities).toHaveBeenCalled());
  77. expect(screen.queryByText(/Home Assistant is not configured/)).not.toBeInTheDocument();
  78. });
  79. it('only offers temperature, humidity, and battery entities, not other device classes', async () => {
  80. getSettings.mockResolvedValue(settings());
  81. getEntities.mockResolvedValue([
  82. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  83. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  84. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
  85. { entity_id: 'binary_sensor.drybox_1_door', friendly_name: 'Drybox 1 Door', domain: 'binary_sensor', device_class: 'door', unit_of_measurement: null, state: 'off' },
  86. ] as never);
  87. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  88. expect(await screen.findByText('Drybox 1 Temp')).toBeInTheDocument();
  89. expect(screen.getByText('Drybox 1 Humidity')).toBeInTheDocument();
  90. expect(screen.getByText('Drybox 1 Battery')).toBeInTheDocument();
  91. expect(screen.queryByText('Drybox 1 Door')).not.toBeInTheDocument();
  92. });
  93. it('only offers a "below" alert threshold for battery sensors, not "above"', async () => {
  94. getSettings.mockResolvedValue(settings());
  95. getEntities.mockResolvedValue([
  96. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
  97. ] as never);
  98. getLocationSensors.mockResolvedValue([]);
  99. const user = userEvent.setup();
  100. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  101. await user.click(await screen.findByText('Drybox 1 Battery'));
  102. expect(screen.queryByText(/^Above/)).not.toBeInTheDocument();
  103. expect(screen.getByText(/^Below/)).toBeInTheDocument();
  104. expect(screen.getAllByRole('spinbutton')).toHaveLength(1);
  105. });
  106. it('prefills the name field from the entity\'s friendly name, not the raw entity id', async () => {
  107. getSettings.mockResolvedValue(settings());
  108. getEntities.mockResolvedValue([
  109. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  110. ] as never);
  111. const user = userEvent.setup();
  112. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  113. await user.click(await screen.findByText('Drybox 1 Temp'));
  114. expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
  115. });
  116. it('follows the name field to the newly picked entity, as long as it was not typed by hand', async () => {
  117. getSettings.mockResolvedValue(settings());
  118. getEntities.mockResolvedValue([
  119. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  120. { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
  121. ] as never);
  122. const user = userEvent.setup();
  123. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  124. await user.click(await screen.findByText('Drybox 1 Temp'));
  125. expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 1 Temp');
  126. await user.click(screen.getByText('Drybox 2 Temp'));
  127. expect(screen.getByLabelText(/name/i)).toHaveValue('Drybox 2 Temp');
  128. });
  129. it('does not overwrite a hand-typed name when a different entity is picked', async () => {
  130. getSettings.mockResolvedValue(settings());
  131. getEntities.mockResolvedValue([
  132. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  133. { entity_id: 'sensor.drybox_2_temp', friendly_name: 'Drybox 2 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '19.0' },
  134. ] as never);
  135. const user = userEvent.setup();
  136. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  137. await user.click(await screen.findByText('Drybox 1 Temp'));
  138. const nameInput = screen.getByLabelText(/name/i);
  139. await user.clear(nameInput);
  140. await user.type(nameInput, 'My Custom Name');
  141. await user.click(screen.getByText('Drybox 2 Temp'));
  142. expect(screen.getByLabelText(/name/i)).toHaveValue('My Custom Name');
  143. });
  144. it('saves the friendly name, not the raw entity id', async () => {
  145. getSettings.mockResolvedValue(settings());
  146. getEntities.mockResolvedValue([
  147. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  148. ] as never);
  149. getLocationSensors.mockResolvedValue([]);
  150. const user = userEvent.setup();
  151. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  152. await user.click(await screen.findByText('Drybox 1 Temp'));
  153. await user.click(screen.getByRole('button', { name: /save/i }));
  154. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
  155. expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ name: 'Drybox 1 Temp' }));
  156. });
  157. it('slices an oversized unit to the column width, like the name', async () => {
  158. // The unit is snapshotted from Home Assistant, not typed by the user —
  159. // an entity reporting a unit longer than the String(16) column must not
  160. // come back as a 422 on a field the form never showed.
  161. getSettings.mockResolvedValue(settings());
  162. getEntities.mockResolvedValue([
  163. { 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' },
  164. ] as never);
  165. getLocationSensors.mockResolvedValue([]);
  166. const user = userEvent.setup();
  167. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  168. await user.click(await screen.findByText('Drybox 1 Temp'));
  169. await user.click(screen.getByRole('button', { name: /save/i }));
  170. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
  171. expect(createSensor).toHaveBeenCalledWith(
  172. expect.objectContaining({ unit: 'degrees Celsius (integrated)'.slice(0, 16) })
  173. );
  174. });
  175. it('requires a name before saving', async () => {
  176. getSettings.mockResolvedValue(settings());
  177. getEntities.mockResolvedValue([
  178. { entity_id: 'sensor.drybox_1_temp', friendly_name: 'Drybox 1 Temp', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  179. ] as never);
  180. const user = userEvent.setup();
  181. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  182. await user.click(await screen.findByText('Drybox 1 Temp'));
  183. await user.clear(screen.getByLabelText(/name/i));
  184. await user.click(screen.getByRole('button', { name: /save/i }));
  185. expect(await screen.findByText('Enter a display name')).toBeInTheDocument();
  186. expect(createSensor).not.toHaveBeenCalled();
  187. });
  188. it('clears a stale "above" value when saving an existing battery sensor', async () => {
  189. getSettings.mockResolvedValue(settings());
  190. getEntities.mockResolvedValue([]);
  191. getLocationSensors.mockResolvedValue([]);
  192. const user = userEvent.setup();
  193. render(
  194. <LocationHASensorModal
  195. sensor={
  196. {
  197. id: 1,
  198. location_id: 7,
  199. name: 'sensor.drybox_1_battery',
  200. entity_id: 'sensor.drybox_1_battery',
  201. kind: 'numeric',
  202. device_class: 'battery',
  203. unit: '%',
  204. alert_state: null,
  205. alert_above: 95,
  206. alert_below: 15,
  207. notify_on_alert: false,
  208. show_on_card: true,
  209. sort_order: 0,
  210. last_state: null,
  211. last_changed: null,
  212. last_checked: null,
  213. created_at: '',
  214. updated_at: '',
  215. } as never
  216. }
  217. locations={LOCATIONS}
  218. onClose={() => {}}
  219. />
  220. );
  221. await user.click(await screen.findByRole('button', { name: /save/i }));
  222. await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
  223. expect(updateSensor).toHaveBeenCalledWith(1, expect.objectContaining({ alert_above: null, alert_below: 15 }));
  224. });
  225. it('shows the location field only when creating a new sensor', async () => {
  226. getSettings.mockResolvedValue(settings());
  227. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  228. expect(await screen.findByText('Drybox 1')).toBeInTheDocument();
  229. });
  230. it('asks to replace the existing sensor when adding a second temperature sensor', async () => {
  231. getSettings.mockResolvedValue(settings());
  232. getEntities.mockResolvedValue([
  233. {
  234. entity_id: 'sensor.drybox_1_temp_2',
  235. friendly_name: 'Drybox 1 Temp 2',
  236. domain: 'sensor',
  237. device_class: 'temperature',
  238. unit_of_measurement: '°C',
  239. state: '21.0',
  240. },
  241. ] as never);
  242. getLocationSensors.mockResolvedValue([
  243. {
  244. id: 1,
  245. location_id: 7,
  246. name: 'sensor.drybox_1_temp',
  247. entity_id: 'sensor.drybox_1_temp',
  248. kind: 'numeric',
  249. device_class: 'temperature',
  250. unit: '°C',
  251. alert_state: null,
  252. alert_above: null,
  253. alert_below: null,
  254. notify_on_alert: false,
  255. show_on_card: true,
  256. sort_order: 0,
  257. last_state: null,
  258. last_changed: null,
  259. last_checked: null,
  260. created_at: '',
  261. updated_at: '',
  262. },
  263. ] as never);
  264. const user = userEvent.setup();
  265. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  266. await user.click(await screen.findByText('Drybox 1 Temp 2'));
  267. await user.click(screen.getByRole('button', { name: /save/i }));
  268. expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
  269. expect(
  270. screen.getByText(/already has a temperature sensor bound: sensor\.drybox_1_temp\./)
  271. ).toBeInTheDocument();
  272. });
  273. it('asks to replace the existing sensor when adding a second humidity sensor', async () => {
  274. getSettings.mockResolvedValue(settings());
  275. getEntities.mockResolvedValue([
  276. {
  277. entity_id: 'sensor.drybox_1_humidity_2',
  278. friendly_name: 'Drybox 1 Humidity 2',
  279. domain: 'sensor',
  280. device_class: 'humidity',
  281. unit_of_measurement: '%',
  282. state: '45',
  283. },
  284. ] as never);
  285. getLocationSensors.mockResolvedValue([
  286. {
  287. id: 2,
  288. location_id: 7,
  289. name: 'sensor.drybox_1_humidity',
  290. entity_id: 'sensor.drybox_1_humidity',
  291. kind: 'numeric',
  292. device_class: 'humidity',
  293. unit: '%',
  294. alert_state: null,
  295. alert_above: null,
  296. alert_below: null,
  297. notify_on_alert: false,
  298. show_on_card: true,
  299. sort_order: 0,
  300. last_state: null,
  301. last_changed: null,
  302. last_checked: null,
  303. created_at: '',
  304. updated_at: '',
  305. },
  306. ] as never);
  307. const user = userEvent.setup();
  308. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  309. await user.click(await screen.findByText('Drybox 1 Humidity 2'));
  310. await user.click(screen.getByRole('button', { name: /save/i }));
  311. expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
  312. expect(
  313. screen.getByText(/already has a humidity sensor bound: sensor\.drybox_1_humidity\./)
  314. ).toBeInTheDocument();
  315. });
  316. it('offers to bind sibling entities when adding the first sensor for a location', async () => {
  317. getSettings.mockResolvedValue(settings());
  318. getEntities.mockResolvedValue([
  319. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  320. // Oversized unit: the auto-add path must slice it to the String(16)
  321. // column just like the primary save does.
  322. { 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' },
  323. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
  324. ] as never);
  325. getLocationSensors.mockResolvedValue([]);
  326. const user = userEvent.setup();
  327. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  328. await user.click(await screen.findByText('Drybox 1 Humidity'));
  329. await user.click(screen.getByRole('button', { name: /save/i }));
  330. expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
  331. const temperatureCheckbox = screen.getByRole('checkbox', { name: /drybox 1 temperature/i });
  332. const batteryCheckbox = screen.getByRole('checkbox', { name: /drybox 1 battery/i });
  333. expect(temperatureCheckbox).toBeChecked();
  334. expect(batteryCheckbox).toBeChecked();
  335. await user.click(screen.getByRole('button', { name: /confirm/i }));
  336. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
  337. expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
  338. expect(createSensor).toHaveBeenNthCalledWith(
  339. 2,
  340. expect.objectContaining({
  341. entity_id: 'sensor.drybox_1_temperature',
  342. unit: 'degrees Celsius (integrated)'.slice(0, 16),
  343. })
  344. );
  345. expect(createSensor).toHaveBeenNthCalledWith(3, expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
  346. });
  347. it('only adds sibling entities left checked in the auto-add dialog', async () => {
  348. getSettings.mockResolvedValue(settings());
  349. getEntities.mockResolvedValue([
  350. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  351. { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  352. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
  353. ] as never);
  354. getLocationSensors.mockResolvedValue([]);
  355. const user = userEvent.setup();
  356. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  357. await user.click(await screen.findByText('Drybox 1 Humidity'));
  358. await user.click(screen.getByRole('button', { name: /save/i }));
  359. expect(await screen.findByText('Add the other sensors too?')).toBeInTheDocument();
  360. await user.click(screen.getByRole('checkbox', { name: /drybox 1 battery/i }));
  361. await user.click(screen.getByRole('button', { name: /confirm/i }));
  362. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(2));
  363. expect(createSensor).toHaveBeenNthCalledWith(1, expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
  364. expect(createSensor).toHaveBeenNthCalledWith(2, expect.objectContaining({ entity_id: 'sensor.drybox_1_temperature' }));
  365. expect(createSensor).not.toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_battery' }));
  366. });
  367. it('declining the auto-add prompt still saves the primary sensor, and says so on the button', async () => {
  368. // "Cancel" here doesn't abandon the save — it saves the sensor the user
  369. // picked and only skips the siblings — so the button must say that
  370. // rather than the default "Cancel", which would read as discarding
  371. // everything.
  372. getSettings.mockResolvedValue(settings());
  373. getEntities.mockResolvedValue([
  374. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  375. { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  376. ] as never);
  377. getLocationSensors.mockResolvedValue([]);
  378. const user = userEvent.setup();
  379. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  380. await user.click(await screen.findByText('Drybox 1 Humidity'));
  381. await user.click(screen.getByRole('button', { name: /save/i }));
  382. const title = await screen.findByText('Add the other sensors too?');
  383. const confirmDialog = within(title.closest('.bg-bambu-dark-secondary') as HTMLElement);
  384. expect(confirmDialog.queryByRole('button', { name: /^cancel$/i })).not.toBeInTheDocument();
  385. await user.click(confirmDialog.getByRole('button', { name: /only this one/i }));
  386. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
  387. expect(createSensor).toHaveBeenCalledWith(expect.objectContaining({ entity_id: 'sensor.drybox_1_humidity' }));
  388. });
  389. it('applies the saved per-category defaults to auto-added sibling sensors', async () => {
  390. vi.mocked(window.localStorage.getItem).mockReturnValue(
  391. JSON.stringify({ temperature: true, battery: false, humidity: true })
  392. );
  393. getSettings.mockResolvedValue(
  394. settings({
  395. location_sensor_alert_defaults: JSON.stringify({
  396. temperature: { alertAbove: '30', alertBelow: '', notifyOnAlert: true },
  397. battery: { alertAbove: '', alertBelow: '15', notifyOnAlert: true },
  398. humidity: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
  399. }),
  400. })
  401. );
  402. getEntities.mockResolvedValue([
  403. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  404. { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature', domain: 'sensor', device_class: 'temperature', unit_of_measurement: '°C', state: '21.0' },
  405. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery', domain: 'sensor', device_class: 'battery', unit_of_measurement: '%', state: '90' },
  406. ] as never);
  407. getLocationSensors.mockResolvedValue([]);
  408. const user = userEvent.setup();
  409. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  410. await user.click(await screen.findByText('Drybox 1 Humidity'));
  411. await user.click(screen.getByRole('button', { name: /save/i }));
  412. await screen.findByText('Add the other sensors too?');
  413. await user.click(screen.getByRole('button', { name: /confirm/i }));
  414. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(3));
  415. expect(createSensor).toHaveBeenNthCalledWith(
  416. 2,
  417. expect.objectContaining({
  418. entity_id: 'sensor.drybox_1_temperature',
  419. alert_above: 30,
  420. alert_below: null,
  421. notify_on_alert: true,
  422. show_on_card: true,
  423. })
  424. );
  425. expect(createSensor).toHaveBeenNthCalledWith(
  426. 3,
  427. expect.objectContaining({
  428. entity_id: 'sensor.drybox_1_battery',
  429. alert_above: null,
  430. alert_below: 15,
  431. notify_on_alert: true,
  432. show_on_card: false,
  433. })
  434. );
  435. });
  436. it('prefills the form from saved category defaults when picking an entity while creating', async () => {
  437. // Alert thresholds come from the server setting; only show-on-card is
  438. // still per-browser (#2824 review round 4).
  439. vi.mocked(window.localStorage.getItem).mockReturnValue(JSON.stringify({ humidity: false }));
  440. getSettings.mockResolvedValue(
  441. settings({
  442. location_sensor_alert_defaults: JSON.stringify({
  443. humidity: { alertAbove: '70', alertBelow: '20', notifyOnAlert: true },
  444. }),
  445. })
  446. );
  447. getEntities.mockResolvedValue([
  448. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  449. ] as never);
  450. getLocationSensors.mockResolvedValue([]);
  451. const user = userEvent.setup();
  452. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  453. await user.click(await screen.findByText('Drybox 1 Humidity'));
  454. const [aboveInput, belowInput] = screen.getAllByRole('spinbutton');
  455. expect(aboveInput).toHaveValue(70);
  456. expect(belowInput).toHaveValue(20);
  457. expect(screen.getByRole('checkbox', { name: /show on filament card/i })).not.toBeChecked();
  458. expect(screen.getByRole('checkbox', { name: /send a notification/i })).toBeChecked();
  459. });
  460. it('does not overwrite an edited sensor with saved defaults when re-selecting its entity', async () => {
  461. vi.mocked(window.localStorage.getItem).mockReturnValue(
  462. JSON.stringify({
  463. temperature: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
  464. humidity: { alertAbove: '999', alertBelow: '111', notifyOnAlert: true, showOnCard: false },
  465. battery: { alertAbove: '', alertBelow: '', notifyOnAlert: false, showOnCard: true },
  466. })
  467. );
  468. getSettings.mockResolvedValue(settings());
  469. getEntities.mockResolvedValue([
  470. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  471. ] as never);
  472. getLocationSensors.mockResolvedValue([]);
  473. const user = userEvent.setup();
  474. render(
  475. <LocationHASensorModal
  476. sensor={
  477. {
  478. id: 1,
  479. location_id: 7,
  480. name: 'sensor.drybox_1_humidity',
  481. entity_id: 'sensor.drybox_1_humidity',
  482. kind: 'numeric',
  483. device_class: 'humidity',
  484. unit: '%',
  485. alert_state: null,
  486. alert_above: 55,
  487. alert_below: null,
  488. notify_on_alert: false,
  489. show_on_card: true,
  490. sort_order: 0,
  491. last_state: null,
  492. last_changed: null,
  493. last_checked: null,
  494. created_at: '',
  495. updated_at: '',
  496. } as never
  497. }
  498. locations={LOCATIONS}
  499. onClose={() => {}}
  500. />
  501. );
  502. await user.click(await screen.findByText('Drybox 1 Humidity'));
  503. const [aboveInput] = screen.getAllByRole('spinbutton');
  504. expect(aboveInput).toHaveValue(55);
  505. });
  506. it('says so when no sibling entities are found for the first sensor of a location', async () => {
  507. getSettings.mockResolvedValue(settings());
  508. getEntities.mockResolvedValue([
  509. { entity_id: 'sensor.drybox_1_humidity', friendly_name: 'Drybox 1 Humidity', domain: 'sensor', device_class: 'humidity', unit_of_measurement: '%', state: '40' },
  510. ] as never);
  511. getLocationSensors.mockResolvedValue([]);
  512. const user = userEvent.setup();
  513. render(<LocationHASensorModal locations={LOCATIONS} onClose={() => {}} />);
  514. await user.click(await screen.findByText('Drybox 1 Humidity'));
  515. await user.click(screen.getByRole('button', { name: /save/i }));
  516. expect(screen.queryByText('Add the other sensors too?')).not.toBeInTheDocument();
  517. expect(
  518. await screen.findByText(/No matching temperature, humidity, or battery sensors found/)
  519. ).toBeInTheDocument();
  520. await waitFor(() => expect(createSensor).toHaveBeenCalledTimes(1));
  521. });
  522. });
  523. describe('LocationHASensorModal — overwrite path (#2824)', () => {
  524. beforeEach(() => {
  525. getSettings.mockReset();
  526. getEntities.mockReset();
  527. getEntities.mockResolvedValue([]);
  528. getLocationSensors.mockReset();
  529. getLocationSensors.mockResolvedValue([]);
  530. createSensor.mockReset();
  531. createSensor.mockResolvedValue({} as never);
  532. updateSensor.mockReset();
  533. updateSensor.mockResolvedValue({} as never);
  534. vi.mocked(window.localStorage.getItem).mockReset();
  535. });
  536. const existingSensor = {
  537. id: 42,
  538. location_id: 7,
  539. name: 'Old Drybox Temp',
  540. entity_id: 'sensor.drybox_1_temp_old',
  541. kind: 'numeric',
  542. device_class: 'temperature',
  543. unit: '°C',
  544. alert_state: null,
  545. alert_above: null,
  546. alert_below: null,
  547. notify_on_alert: false,
  548. show_on_card: true,
  549. };
  550. it('PATCHes the existing sensor onto the new entity instead of deleting and recreating', async () => {
  551. // Regression: delete-then-create left a window where, if the create
  552. // failed after the delete succeeded, the location's binding was gone
  553. // with nothing in its place. A single PATCH avoids that window, and
  554. // this also proves nothing calls deleteLocationHASensor on this path.
  555. getSettings.mockResolvedValue(settings());
  556. getEntities.mockResolvedValue([
  557. {
  558. entity_id: 'sensor.drybox_1_temp_new',
  559. friendly_name: 'Drybox 1 Temp (new)',
  560. domain: 'sensor',
  561. device_class: 'temperature',
  562. unit_of_measurement: '°C',
  563. state: '22.0',
  564. },
  565. ] as never);
  566. getLocationSensors.mockResolvedValue([existingSensor] as never);
  567. const onClose = vi.fn();
  568. const user = userEvent.setup();
  569. render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
  570. await user.click(await screen.findByText('Drybox 1 Temp (new)'));
  571. await user.click(screen.getByRole('button', { name: /save/i }));
  572. expect(await screen.findByText('Replace existing sensor?')).toBeInTheDocument();
  573. await user.click(screen.getByRole('button', { name: 'Confirm' }));
  574. await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(1));
  575. expect(updateSensor).toHaveBeenCalledWith(
  576. 42,
  577. expect.objectContaining({ entity_id: 'sensor.drybox_1_temp_new', name: 'Drybox 1 Temp (new)' })
  578. );
  579. expect(createSensor).not.toHaveBeenCalled();
  580. expect(onClose).toHaveBeenCalled();
  581. });
  582. it('does not close on Escape or backdrop click while the overwrite PATCH is in flight', async () => {
  583. getSettings.mockResolvedValue(settings());
  584. getEntities.mockResolvedValue([
  585. {
  586. entity_id: 'sensor.drybox_1_temp_new',
  587. friendly_name: 'Drybox 1 Temp (new)',
  588. domain: 'sensor',
  589. device_class: 'temperature',
  590. unit_of_measurement: '°C',
  591. state: '22.0',
  592. },
  593. ] as never);
  594. getLocationSensors.mockResolvedValue([existingSensor] as never);
  595. let resolveUpdate: (() => void) | undefined;
  596. updateSensor.mockImplementation(
  597. () =>
  598. new Promise((resolve) => {
  599. resolveUpdate = () => resolve({} as never);
  600. })
  601. );
  602. const onClose = vi.fn();
  603. const user = userEvent.setup();
  604. const { container } = render(<LocationHASensorModal locations={LOCATIONS} onClose={onClose} />);
  605. await user.click(await screen.findByText('Drybox 1 Temp (new)'));
  606. await user.click(screen.getByRole('button', { name: /save/i }));
  607. await screen.findByText('Replace existing sensor?');
  608. await user.click(screen.getByRole('button', { name: 'Confirm' }));
  609. await waitFor(() => expect(updateSensor).toHaveBeenCalled());
  610. await user.keyboard('{Escape}');
  611. const backdrop = container.querySelector('.fixed.inset-0.bg-black\\/70');
  612. if (backdrop) await user.click(backdrop);
  613. expect(onClose).not.toHaveBeenCalled();
  614. resolveUpdate?.();
  615. await waitFor(() => expect(onClose).toHaveBeenCalled());
  616. });
  617. });