InventoryPageLocationSensorRequests.test.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /**
  2. * Location-sensor readings should cost one request per location, not two.
  3. *
  4. * Card view (SpoolLocationFooter) and the table's Temperature/Humidity/
  5. * Battery columns used to each fetch their own copy — the footer under a
  6. * 'cardOnly' key, the table under an 'all' key — so a location with sensors
  7. * bound cost two requests per poll interval whenever a card was on screen,
  8. * and the table's 'all' request fired even with all three sensor columns
  9. * hidden, which is the default. Both queries now share one key and one
  10. * unfiltered fetch; the footer filters to show_on_card itself.
  11. */
  12. import { describe, it, expect, beforeEach, vi } from 'vitest';
  13. import { screen, waitFor } from '@testing-library/react';
  14. import userEvent from '@testing-library/user-event';
  15. import { render } from '../utils';
  16. import InventoryPageRouter from '../../pages/InventoryPage';
  17. import { http, HttpResponse } from 'msw';
  18. import { server } from '../mocks/server';
  19. const mockSpool = {
  20. id: 1,
  21. material: 'PLA',
  22. subtype: null,
  23. brand: 'Polymaker',
  24. color_name: 'Red',
  25. rgba: 'FF0000FF',
  26. label_weight: 1000,
  27. core_weight: 250,
  28. weight_used: 100,
  29. slicer_filament: null,
  30. slicer_filament_name: null,
  31. nozzle_temp_min: null,
  32. nozzle_temp_max: null,
  33. note: null,
  34. added_full: null,
  35. last_used: null,
  36. encode_time: null,
  37. tag_uid: null,
  38. tray_uuid: null,
  39. data_origin: null,
  40. tag_type: null,
  41. archived_at: null,
  42. created_at: '2025-01-01T00:00:00Z',
  43. updated_at: '2025-01-01T00:00:00Z',
  44. k_profiles: [],
  45. cost_per_kg: null,
  46. last_scale_weight: null,
  47. last_weighed_at: null,
  48. location_id: 7,
  49. storage_location: 'Drybox 1',
  50. };
  51. const mockSpoolAtUnsensoredLocation = {
  52. ...mockSpool,
  53. id: 2,
  54. brand: 'eSun',
  55. location_id: 8,
  56. storage_location: 'Shelf 2',
  57. };
  58. const mockSensor = {
  59. id: 1,
  60. location_id: 7,
  61. name: 'Drybox 1 Temperature',
  62. entity_id: 'sensor.drybox_1_temperature',
  63. kind: 'numeric',
  64. device_class: 'temperature',
  65. unit: '°C',
  66. alert_state: null,
  67. alert_above: null,
  68. alert_below: null,
  69. notify_on_alert: false,
  70. show_on_card: true,
  71. sort_order: 0,
  72. last_state: '24.5',
  73. last_changed: '2025-01-01T00:00:00Z',
  74. created_at: '2025-01-01T00:00:00Z',
  75. updated_at: '2025-01-01T00:00:00Z',
  76. };
  77. const mockReading = {
  78. id: 1,
  79. name: 'Drybox 1 Temperature',
  80. entity_id: 'sensor.drybox_1_temperature',
  81. kind: 'numeric',
  82. device_class: 'temperature',
  83. unit: '°C',
  84. state: '24.5',
  85. value: 24.5,
  86. alerting: false,
  87. reachable: true,
  88. alert_state: null,
  89. alert_above: null,
  90. alert_below: null,
  91. last_changed: '2025-01-01T00:00:00Z',
  92. show_on_card: true,
  93. };
  94. const COLUMN_CONFIG_KEY = 'bambuddy-inventory-columns';
  95. describe('InventoryPage - location sensor readings request count', () => {
  96. let readingsRequestCount: number;
  97. beforeEach(() => {
  98. vi.mocked(window.localStorage.getItem).mockReset();
  99. vi.mocked(window.localStorage.setItem).mockReset();
  100. readingsRequestCount = 0;
  101. server.use(
  102. http.get('/api/v1/inventory/spools', () => HttpResponse.json([mockSpool])),
  103. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([mockSensor])),
  104. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
  105. readingsRequestCount += 1;
  106. return HttpResponse.json([mockReading]);
  107. }),
  108. );
  109. });
  110. it('does not fetch readings in table view with sensor columns hidden (the default)', async () => {
  111. render(<InventoryPageRouter />);
  112. await waitFor(() => {
  113. expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0);
  114. });
  115. // Give any stray request a moment to land before asserting its absence.
  116. await new Promise((r) => setTimeout(r, 50));
  117. expect(readingsRequestCount).toBe(0);
  118. });
  119. it('fetches readings exactly once in table view when a sensor column is visible', async () => {
  120. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  121. key === COLUMN_CONFIG_KEY
  122. ? JSON.stringify([{ id: 'temperature', label: 'Temperature', visible: true }])
  123. : null
  124. );
  125. render(<InventoryPageRouter />);
  126. await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
  127. await new Promise((r) => setTimeout(r, 50));
  128. expect(readingsRequestCount).toBe(1);
  129. });
  130. it('fetches readings exactly once in card view, shared between the table query and the footer', async () => {
  131. const user = userEvent.setup();
  132. render(<InventoryPageRouter />);
  133. await waitFor(() => expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0));
  134. await user.click(screen.getByText('Cards'));
  135. await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
  136. await new Promise((r) => setTimeout(r, 50));
  137. expect(readingsRequestCount).toBe(1);
  138. });
  139. it('hides a sensor with show_on_card=false from the card footer without a second request', async () => {
  140. server.use(
  141. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
  142. readingsRequestCount += 1;
  143. return HttpResponse.json([{ ...mockReading, show_on_card: false }]);
  144. }),
  145. );
  146. const user = userEvent.setup();
  147. render(<InventoryPageRouter />);
  148. await waitFor(() => expect(screen.getAllByText('Polymaker').length).toBeGreaterThan(0));
  149. await user.click(screen.getByText('Cards'));
  150. // The footer never renders — its one sensor is hidden from cards — but
  151. // that must be a client-side filter, not a second, differently-scoped
  152. // request.
  153. await new Promise((r) => setTimeout(r, 50));
  154. expect(screen.queryByText('24.50 °C')).toBeNull();
  155. expect(readingsRequestCount).toBe(1);
  156. });
  157. it('never queries a location with no bound sensor, in either view', async () => {
  158. // The gate that started this file: a location absent from
  159. // getLocationHASensors() must never appear in the readings useQueries
  160. // array at all — not merely disabled — so it costs nothing whether its
  161. // card/row is on screen or not. Most installs have far more storage
  162. // locations than ones actually wired up to Home Assistant.
  163. let unsensoredLocationRequestCount = 0;
  164. vi.mocked(window.localStorage.getItem).mockImplementation((key: string) =>
  165. key === COLUMN_CONFIG_KEY
  166. ? JSON.stringify([{ id: 'temperature', label: 'Temperature', visible: true }])
  167. : null
  168. );
  169. server.use(
  170. http.get('/api/v1/inventory/spools', () =>
  171. HttpResponse.json([mockSpool, mockSpoolAtUnsensoredLocation])
  172. ),
  173. http.get('/api/v1/location-ha-sensors/by-location/8/readings', () => {
  174. unsensoredLocationRequestCount += 1;
  175. return HttpResponse.json([]);
  176. }),
  177. );
  178. const user = userEvent.setup();
  179. render(<InventoryPageRouter />);
  180. // Table view, temperature column visible: location 7 (has a sensor) is queried.
  181. await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
  182. expect(screen.getAllByText('eSun').length).toBeGreaterThan(0);
  183. await new Promise((r) => setTimeout(r, 50));
  184. expect(readingsRequestCount).toBe(1);
  185. expect(unsensoredLocationRequestCount).toBe(0);
  186. // Card view too — the footer for location 8's card must not query it either.
  187. await user.click(screen.getByText('Cards'));
  188. await waitFor(() => expect(screen.getAllByText('eSun').length).toBeGreaterThan(0));
  189. await new Promise((r) => setTimeout(r, 50));
  190. expect(unsensoredLocationRequestCount).toBe(0);
  191. });
  192. });