LocationSensorOptionsModal.test.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { act, screen, waitFor } from '@testing-library/react';
  2. import userEvent from '@testing-library/user-event';
  3. import { beforeEach, describe, expect, it, vi } from 'vitest';
  4. import { api } from '../../api/client';
  5. import { LocationSensorOptionsModal } from '../../components/LocationSensorOptionsModal';
  6. import { render } from '../utils';
  7. import {
  8. defaultLocationSensorDefaults,
  9. serializeLocationSensorAlertDefaults,
  10. } from '../../utils/locationSensorDefaults';
  11. vi.mock('../../api/client', async () => {
  12. const actual = await vi.importActual<typeof import('../../api/client')>('../../api/client');
  13. return {
  14. ...actual,
  15. api: {
  16. ...actual.api,
  17. getLocationHASensors: vi.fn(),
  18. getBindableLocationHAEntities: vi.fn(),
  19. updateLocationHASensor: vi.fn(),
  20. getSettings: vi.fn(),
  21. updateSettings: vi.fn(),
  22. },
  23. };
  24. });
  25. const getLocationSensors = vi.mocked(api.getLocationHASensors);
  26. const getEntities = vi.mocked(api.getBindableLocationHAEntities);
  27. const updateSensor = vi.mocked(api.updateLocationHASensor);
  28. const getSettings = vi.mocked(api.getSettings);
  29. const updateSettings = vi.mocked(api.updateSettings);
  30. describe('LocationSensorOptionsModal', () => {
  31. beforeEach(() => {
  32. vi.mocked(window.localStorage.getItem).mockReset();
  33. vi.mocked(window.localStorage.setItem).mockReset();
  34. getLocationSensors.mockReset();
  35. getEntities.mockReset();
  36. updateSensor.mockReset();
  37. getSettings.mockReset();
  38. updateSettings.mockReset();
  39. getEntities.mockResolvedValue([]);
  40. getSettings.mockResolvedValue({ location_sensor_poll_interval: 120 } as never);
  41. updateSettings.mockResolvedValue({} as never);
  42. });
  43. it('shows a section for each of the three auto-add categories', async () => {
  44. render(<LocationSensorOptionsModal onClose={() => {}} />);
  45. expect(await screen.findByText('Temperature')).toBeInTheDocument();
  46. expect(screen.getByText('Humidity')).toBeInTheDocument();
  47. expect(screen.getByText('Battery')).toBeInTheDocument();
  48. });
  49. // The alert thresholds are server-backed (#2824 review round 4): they seed
  50. // the rule written onto each sensor row, so they must not differ per browser
  51. // and have to survive a backup/restore. Only the show-on-card default is
  52. // still local, because show_on_card is decided per sensor.
  53. it('saves the entered alert defaults to the server and closes', async () => {
  54. const onClose = vi.fn();
  55. const user = userEvent.setup();
  56. render(<LocationSensorOptionsModal onClose={onClose} />);
  57. const aboveInputs = screen.getAllByText('Above °C');
  58. expect(aboveInputs.length).toBeGreaterThan(0);
  59. const inputs = screen.getAllByRole('spinbutton');
  60. await user.clear(inputs[0]);
  61. await user.type(inputs[0], '35');
  62. await user.click(screen.getByRole('button', { name: /save/i }));
  63. await waitFor(() =>
  64. expect(updateSettings).toHaveBeenCalledWith(
  65. expect.objectContaining({
  66. location_sensor_alert_defaults: expect.stringContaining('"alertAbove":"35"'),
  67. })
  68. )
  69. );
  70. expect(onClose).toHaveBeenCalled();
  71. });
  72. it('keeps the show-on-card default in localStorage, without the alert fields', async () => {
  73. const user = userEvent.setup();
  74. render(<LocationSensorOptionsModal onClose={() => {}} />);
  75. await screen.findByText('Battery');
  76. await user.click(screen.getByRole('button', { name: /save/i }));
  77. await waitFor(() =>
  78. expect(window.localStorage.setItem).toHaveBeenCalledWith(
  79. 'bambuddy-location-sensor-show-on-card-defaults',
  80. expect.stringContaining('"temperature":true')
  81. )
  82. );
  83. const written = vi
  84. .mocked(window.localStorage.setItem)
  85. .mock.calls.find((call) => call[0] === 'bambuddy-location-sensor-show-on-card-defaults')?.[1];
  86. expect(written).not.toContain('alertAbove');
  87. expect(written).not.toContain('notifyOnAlert');
  88. });
  89. it('does not offer an "above" threshold for the battery section', async () => {
  90. render(<LocationSensorOptionsModal onClose={() => {}} />);
  91. await screen.findByText('Battery');
  92. expect(screen.getAllByText(/^Above (°C|%)$/)).toHaveLength(2);
  93. expect(screen.getAllByText(/^Below (°C|%)$/)).toHaveLength(3);
  94. expect(screen.getAllByRole('spinbutton')).toHaveLength(6);
  95. });
  96. it('clears a stale saved "above" value for battery on save', async () => {
  97. getSettings.mockResolvedValue({
  98. location_sensor_poll_interval: 120,
  99. location_sensor_alert_defaults: JSON.stringify({
  100. temperature: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
  101. humidity: { alertAbove: '', alertBelow: '', notifyOnAlert: false },
  102. battery: { alertAbove: '95', alertBelow: '15', notifyOnAlert: true },
  103. }),
  104. } as never);
  105. const user = userEvent.setup();
  106. render(<LocationSensorOptionsModal onClose={() => {}} />);
  107. await screen.findByText('Battery');
  108. await user.click(screen.getByRole('button', { name: /save/i }));
  109. await waitFor(() =>
  110. expect(updateSettings).toHaveBeenCalledWith(
  111. expect.objectContaining({
  112. location_sensor_alert_defaults: expect.stringContaining('"battery":{"alertAbove":"","alertBelow":"15"'),
  113. })
  114. )
  115. );
  116. });
  117. it('saves the chosen above/below/optimal alert colors', async () => {
  118. const user = userEvent.setup();
  119. render(<LocationSensorOptionsModal onClose={() => {}} />);
  120. await screen.findByText('Battery');
  121. await user.selectOptions(screen.getByLabelText(/above threshold color/i), 'orange');
  122. await user.selectOptions(screen.getByLabelText(/below threshold color/i), 'purple');
  123. await user.selectOptions(screen.getByLabelText(/optimal value color/i), 'blue');
  124. await user.click(screen.getByRole('button', { name: /save/i }));
  125. expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-above-color', 'orange');
  126. expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-below-color', 'purple');
  127. expect(window.localStorage.setItem).toHaveBeenCalledWith('bambuddy-location-sensor-alert-optimal-color', 'blue');
  128. });
  129. it('loads the current poll interval and saves a changed value', async () => {
  130. getSettings.mockResolvedValue({ location_sensor_poll_interval: 300 } as never);
  131. const user = userEvent.setup();
  132. render(<LocationSensorOptionsModal onClose={() => {}} />);
  133. const input = await screen.findByLabelText(/update interval/i);
  134. await waitFor(() => expect(input).toHaveValue(300));
  135. await user.clear(input);
  136. await user.type(input, '90');
  137. await user.click(screen.getByRole('button', { name: /save/i }));
  138. await waitFor(() =>
  139. expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ location_sensor_poll_interval: 90 }))
  140. );
  141. });
  142. // Both server-backed fields are seeded into local state by an effect, so a
  143. // settings response landing after the user has started typing used to
  144. // overwrite the edit — a cleared-and-retyped threshold came out as "3035".
  145. // Covers the interval too, which had the same shape before this guard.
  146. it('does not overwrite an in-progress edit when the settings response lands late', async () => {
  147. let resolveSettings: (value: unknown) => void = () => {};
  148. getSettings.mockReturnValue(
  149. new Promise((resolve) => {
  150. resolveSettings = resolve;
  151. }) as never
  152. );
  153. const user = userEvent.setup();
  154. render(<LocationSensorOptionsModal onClose={() => {}} />);
  155. // Form is up on the built-ins; edit before the server has answered.
  156. const inputs = screen.getAllByRole('spinbutton');
  157. await user.clear(inputs[0]);
  158. await user.type(inputs[0], '35');
  159. // Resolve and let the query actually propagate before asserting. A bare
  160. // waitFor would pass on its first tick — before the response reaches the
  161. // effect — and so would succeed even with the guard removed.
  162. await act(async () => {
  163. resolveSettings({
  164. location_sensor_poll_interval: 900,
  165. location_sensor_alert_defaults: JSON.stringify({
  166. temperature: { alertAbove: '30', alertBelow: '20', notifyOnAlert: false },
  167. }),
  168. });
  169. await new Promise((resolve) => setTimeout(resolve, 50));
  170. });
  171. // The late response must not put the server's 30 back over the typed 35.
  172. expect(screen.getAllByRole('spinbutton')[0]).toHaveValue(35);
  173. });
  174. it('still seeds the fields the user did not touch when the settings response lands late', async () => {
  175. // The other half of the guard above. Skipping the seed outright because a
  176. // keystroke beat the response would leave every untouched field on the
  177. // built-ins, and Save would then write those over the server's values for
  178. // fields the user never saw. "Seeded" and "touched" are tracked apart so
  179. // the seed still lands everywhere the user has not typed.
  180. let resolveSettings: (value: unknown) => void = () => {};
  181. getSettings.mockReturnValue(
  182. new Promise((resolve) => {
  183. resolveSettings = resolve;
  184. }) as never
  185. );
  186. const user = userEvent.setup();
  187. render(<LocationSensorOptionsModal onClose={() => {}} />);
  188. const inputs = screen.getAllByRole('spinbutton');
  189. await user.clear(inputs[0]);
  190. await user.type(inputs[0], '35');
  191. await act(async () => {
  192. resolveSettings({
  193. location_sensor_poll_interval: 900,
  194. location_sensor_alert_defaults: JSON.stringify({
  195. humidity: { alertAbove: '55', alertBelow: '25', notifyOnAlert: true },
  196. }),
  197. });
  198. await new Promise((resolve) => setTimeout(resolve, 50));
  199. });
  200. await user.click(screen.getByRole('button', { name: /save/i }));
  201. await waitFor(() => expect(updateSettings).toHaveBeenCalled());
  202. const patch = updateSettings.mock.calls[0][0] as Record<string, unknown>;
  203. // Seeded, so it matches the server and is left out of the patch entirely.
  204. // Unseeded it would still read 120 and be sent as a change nobody made.
  205. expect(patch.location_sensor_poll_interval).toBeUndefined();
  206. // The typed value survived...
  207. expect(patch.location_sensor_alert_defaults).toContain('"alertAbove":"35"');
  208. // ...and the category never touched kept the server's 55, not the built-in 30.
  209. expect(patch.location_sensor_alert_defaults).toContain('"alertAbove":"55"');
  210. });
  211. it('does not call updateSettings when nothing on the server side changed', async () => {
  212. // Both server-backed fields already match what the form would submit, so
  213. // an untouched Save must not issue a PATCH that needs admin rights.
  214. getSettings.mockResolvedValue({
  215. location_sensor_poll_interval: 120,
  216. location_sensor_alert_defaults: serializeLocationSensorAlertDefaults(defaultLocationSensorDefaults()),
  217. } as never);
  218. const onClose = vi.fn();
  219. const user = userEvent.setup();
  220. render(<LocationSensorOptionsModal onClose={onClose} />);
  221. await screen.findByLabelText(/update interval/i);
  222. await user.click(screen.getByRole('button', { name: /save/i }));
  223. await waitFor(() => expect(onClose).toHaveBeenCalled());
  224. expect(updateSettings).not.toHaveBeenCalled();
  225. });
  226. it('shows an error and keeps the modal open when saving a changed interval fails', async () => {
  227. updateSettings.mockRejectedValue(new Error('Forbidden'));
  228. const onClose = vi.fn();
  229. const user = userEvent.setup();
  230. render(<LocationSensorOptionsModal onClose={onClose} />);
  231. const input = await screen.findByLabelText(/update interval/i);
  232. await user.clear(input);
  233. await user.type(input, '90');
  234. await user.click(screen.getByRole('button', { name: /save/i }));
  235. expect(await screen.findByText('Forbidden')).toBeInTheDocument();
  236. expect(onClose).not.toHaveBeenCalled();
  237. // The server call happens before the localStorage writes, so a failed
  238. // PATCH must leave every local preference untouched — the error toast
  239. // says nothing was saved, and that has to stay true.
  240. const writtenKeys = vi.mocked(window.localStorage.setItem).mock.calls.map((call) => call[0]);
  241. expect(writtenKeys).not.toContain('bambuddy-location-sensor-show-on-card-defaults');
  242. expect(writtenKeys).not.toContain('bambuddy-location-sensor-colorize-values');
  243. expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-above-color');
  244. expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-below-color');
  245. expect(writtenKeys).not.toContain('bambuddy-location-sensor-alert-optimal-color');
  246. });
  247. it('clamps a poll interval below the 60s minimum on blur', async () => {
  248. const user = userEvent.setup();
  249. render(<LocationSensorOptionsModal onClose={() => {}} />);
  250. const input = await screen.findByLabelText(/update interval/i);
  251. await waitFor(() => expect(input).toHaveValue(120));
  252. await user.clear(input);
  253. await user.type(input, '10');
  254. await user.tab();
  255. expect(input).toHaveValue(60);
  256. });
  257. it('disables the color pickers when colorizing is turned off', async () => {
  258. const user = userEvent.setup();
  259. render(<LocationSensorOptionsModal onClose={() => {}} />);
  260. await screen.findByText('Battery');
  261. await user.click(screen.getByLabelText(/colorize sensor values/i));
  262. expect(screen.getByLabelText(/above threshold color/i)).toBeDisabled();
  263. expect(screen.getByLabelText(/below threshold color/i)).toBeDisabled();
  264. expect(screen.getByLabelText(/optimal value color/i)).toBeDisabled();
  265. });
  266. it('asks for confirmation before overwriting existing sensors, then applies the configured values', async () => {
  267. getLocationSensors.mockResolvedValue([
  268. { id: 1, device_class: 'temperature', entity_id: 'sensor.drybox_1_temperature' } as never,
  269. { id: 2, device_class: 'humidity', entity_id: 'sensor.drybox_1_humidity' } as never,
  270. { id: 3, device_class: 'battery', entity_id: 'sensor.drybox_1_battery' } as never,
  271. { id: 4, device_class: 'door', entity_id: 'binary_sensor.drybox_1_door' } as never,
  272. ]);
  273. getEntities.mockResolvedValue([
  274. { entity_id: 'sensor.drybox_1_temperature', friendly_name: 'Drybox 1 Temperature' } as never,
  275. { entity_id: 'sensor.drybox_1_battery', friendly_name: 'Drybox 1 Battery' } as never,
  276. ]);
  277. updateSensor.mockResolvedValue({} as never);
  278. const onClose = vi.fn();
  279. const user = userEvent.setup();
  280. render(<LocationSensorOptionsModal onClose={onClose} />);
  281. await screen.findByText('Battery');
  282. const inputs = screen.getAllByRole('spinbutton');
  283. await user.clear(inputs[0]);
  284. await user.type(inputs[0], '35');
  285. await user.click(screen.getByRole('button', { name: /^reset$/i }));
  286. expect(updateSensor).not.toHaveBeenCalled();
  287. expect(await screen.findByText(/cannot be undone/i)).toBeInTheDocument();
  288. await user.click(screen.getAllByRole('button', { name: /^reset$/i })[1]);
  289. await waitFor(() => expect(updateSensor).toHaveBeenCalledTimes(3));
  290. expect(updateSensor).toHaveBeenCalledWith(1, expect.objectContaining({ alert_above: 35, name: 'Drybox 1 Temperature' }));
  291. expect(updateSensor).toHaveBeenCalledWith(3, expect.objectContaining({ alert_above: null, name: 'Drybox 1 Battery' }));
  292. // Sensor 2's entity isn't in the Home Assistant list (e.g. currently
  293. // unreachable) — its name is left untouched rather than cleared.
  294. expect(updateSensor).toHaveBeenCalledWith(2, expect.not.objectContaining({ name: expect.anything() }));
  295. expect(onClose).toHaveBeenCalled();
  296. expect(updateSettings).toHaveBeenCalledWith(
  297. expect.objectContaining({
  298. location_sensor_alert_defaults: expect.stringContaining('"alertAbove":"35"'),
  299. })
  300. );
  301. });
  302. it('saves nothing when the reset fails part-way', async () => {
  303. // Reset used to save the options first and rewrite the sensors after, so a
  304. // rejected sensor PATCH left the settings and the six local preferences
  305. // saved behind an error toast that said nothing had been. Sensors first,
  306. // options after: a failure now means the toast is true.
  307. getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
  308. getEntities.mockResolvedValue([]);
  309. updateSensor.mockRejectedValue(new Error('Forbidden'));
  310. const onClose = vi.fn();
  311. const user = userEvent.setup();
  312. render(<LocationSensorOptionsModal onClose={onClose} />);
  313. await screen.findByText('Battery');
  314. const inputs = screen.getAllByRole('spinbutton');
  315. await user.clear(inputs[0]);
  316. await user.type(inputs[0], '35');
  317. await user.click(screen.getByRole('button', { name: /^reset$/i }));
  318. await screen.findByText(/cannot be undone/i);
  319. await user.click(screen.getAllByRole('button', { name: /^reset$/i })[1]);
  320. await waitFor(() => expect(updateSensor).toHaveBeenCalled());
  321. expect(updateSettings).not.toHaveBeenCalled();
  322. // The render itself writes unrelated keys (theme), so scope this to the
  323. // preferences Save owns.
  324. const written = vi.mocked(window.localStorage.setItem).mock.calls.map(([key]) => key);
  325. expect(written.filter((key) => String(key).startsWith('bambuddy-location-sensor'))).toEqual([]);
  326. expect(onClose).not.toHaveBeenCalled();
  327. });
  328. it('does not overwrite anything when the reset confirmation is cancelled', async () => {
  329. getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
  330. const user = userEvent.setup();
  331. render(<LocationSensorOptionsModal onClose={() => {}} />);
  332. await screen.findByText('Battery');
  333. await user.click(screen.getByRole('button', { name: /^reset$/i }));
  334. await screen.findByText(/cannot be undone/i);
  335. const cancelButtons = screen.getAllByRole('button', { name: /cancel/i });
  336. await user.click(cancelButtons[cancelButtons.length - 1]);
  337. expect(updateSensor).not.toHaveBeenCalled();
  338. });
  339. it('dismissing the reset confirm by clicking its own overlay does not close the Options dialog', async () => {
  340. // Regression: ConfirmModal used to render inside the Options overlay's
  341. // onClick=onClose div. ConfirmModal's own overlay doesn't stop
  342. // propagation, so a click meant only to dismiss it bubbled up and closed
  343. // Options too, dropping whatever the user had already changed.
  344. getLocationSensors.mockResolvedValue([{ id: 1, device_class: 'temperature' } as never]);
  345. const onClose = vi.fn();
  346. const user = userEvent.setup();
  347. const { container } = render(<LocationSensorOptionsModal onClose={onClose} />);
  348. await screen.findByText('Battery');
  349. await user.click(screen.getByRole('button', { name: /^reset$/i }));
  350. await screen.findByText(/cannot be undone/i);
  351. const overlays = container.querySelectorAll('.fixed.inset-0');
  352. expect(overlays.length).toBe(2);
  353. const confirmOverlay = overlays[overlays.length - 1];
  354. await user.click(confirmOverlay);
  355. expect(screen.queryByText(/cannot be undone/i)).toBeNull();
  356. expect(onClose).not.toHaveBeenCalled();
  357. });
  358. it('does not persist anything when cancelled', async () => {
  359. const onClose = vi.fn();
  360. const user = userEvent.setup();
  361. render(<LocationSensorOptionsModal onClose={onClose} />);
  362. await user.click(screen.getByRole('button', { name: /cancel/i }));
  363. expect(window.localStorage.setItem).not.toHaveBeenCalledWith(
  364. 'bambuddy-location-sensor-auto-add-defaults',
  365. expect.anything()
  366. );
  367. expect(onClose).toHaveBeenCalled();
  368. });
  369. });