LocationSensorReadingsCrossPageCache.test.tsx 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. /**
  2. * InventoryPage, SpoolLocationFooter, and SettingsPage's sensor overview all
  3. * read one location's live readings under the same query key now
  4. * (['locationHaSensorReadings', locationId], no 'all'/'cardOnly' suffix).
  5. * SettingsPage was missed when the other two were unified and kept its own
  6. * 'all'-suffixed key — this pins that navigating between the two pages in
  7. * the same session reuses the cache instead of firing a second request for
  8. * a location that was just fetched.
  9. */
  10. import { describe, it, expect, beforeEach } from 'vitest';
  11. import { render, screen, waitFor, cleanup } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  14. import { BrowserRouter } from 'react-router-dom';
  15. import { http, HttpResponse } from 'msw';
  16. import { server } from '../mocks/server';
  17. import { AuthProvider } from '../../contexts/AuthContext';
  18. import { ThemeProvider } from '../../contexts/ThemeContext';
  19. import { ToastProvider } from '../../contexts/ToastContext';
  20. import InventoryPageRouter from '../../pages/InventoryPage';
  21. import { SettingsPage } from '../../pages/SettingsPage';
  22. const mockSpool = {
  23. id: 1,
  24. material: 'PLA',
  25. subtype: null,
  26. brand: 'Polymaker',
  27. color_name: 'Red',
  28. rgba: 'FF0000FF',
  29. label_weight: 1000,
  30. core_weight: 250,
  31. weight_used: 100,
  32. slicer_filament: null,
  33. slicer_filament_name: null,
  34. nozzle_temp_min: null,
  35. nozzle_temp_max: null,
  36. note: null,
  37. added_full: null,
  38. last_used: null,
  39. encode_time: null,
  40. tag_uid: null,
  41. tray_uuid: null,
  42. data_origin: null,
  43. tag_type: null,
  44. archived_at: null,
  45. created_at: '2025-01-01T00:00:00Z',
  46. updated_at: '2025-01-01T00:00:00Z',
  47. k_profiles: [],
  48. cost_per_kg: null,
  49. last_scale_weight: null,
  50. last_weighed_at: null,
  51. location_id: 7,
  52. storage_location: 'Drybox 1',
  53. };
  54. const mockSensor = {
  55. id: 1,
  56. location_id: 7,
  57. name: 'Drybox 1 Temperature',
  58. entity_id: 'sensor.drybox_1_temperature',
  59. kind: 'numeric',
  60. device_class: 'temperature',
  61. unit: '°C',
  62. alert_state: null,
  63. alert_above: null,
  64. alert_below: null,
  65. notify_on_alert: false,
  66. show_on_card: true,
  67. sort_order: 0,
  68. last_state: '24.5',
  69. last_changed: '2025-01-01T00:00:00Z',
  70. created_at: '2025-01-01T00:00:00Z',
  71. updated_at: '2025-01-01T00:00:00Z',
  72. };
  73. const mockReading = {
  74. id: 1,
  75. name: 'Drybox 1 Temperature',
  76. entity_id: 'sensor.drybox_1_temperature',
  77. kind: 'numeric',
  78. device_class: 'temperature',
  79. unit: '°C',
  80. state: '24.5',
  81. value: 24.5,
  82. alerting: false,
  83. reachable: true,
  84. alert_state: null,
  85. alert_above: null,
  86. alert_below: null,
  87. last_changed: '2025-01-01T00:00:00Z',
  88. show_on_card: true,
  89. };
  90. // A shared client, mimicking the app's real one: unlike the default test
  91. // client's gcTime: 0, unmounting a page must not evict the cache the next
  92. // page is meant to reuse.
  93. function SharedProviders({ children, client }: { children: React.ReactNode; client: QueryClient }) {
  94. return (
  95. <QueryClientProvider client={client}>
  96. <BrowserRouter>
  97. <AuthProvider>
  98. <ThemeProvider>
  99. <ToastProvider>{children}</ToastProvider>
  100. </ThemeProvider>
  101. </AuthProvider>
  102. </BrowserRouter>
  103. </QueryClientProvider>
  104. );
  105. }
  106. describe('location sensor readings — shared cache across pages', () => {
  107. let readingsRequestCount: number;
  108. let client: QueryClient;
  109. beforeEach(() => {
  110. readingsRequestCount = 0;
  111. // staleTime matches App.tsx's real QueryClient — the default of 0 would
  112. // make every remount refetch regardless of whether the keys line up,
  113. // which is not what production does and would make this test pass for
  114. // the wrong reason.
  115. client = new QueryClient({
  116. defaultOptions: { queries: { retry: false, staleTime: 1000 * 60 }, mutations: { retry: false } },
  117. });
  118. server.use(
  119. http.get('/api/v1/inventory/spools', () => HttpResponse.json([mockSpool])),
  120. http.get('/api/v1/location-ha-sensors/', () => HttpResponse.json([mockSensor])),
  121. http.get('/api/v1/inventory/locations', () =>
  122. HttpResponse.json([{ id: 7, name: 'Drybox 1', identifier: null, spool_count: 1, created_at: '', updated_at: '' }])
  123. ),
  124. http.get('/api/v1/location-ha-sensors/by-location/7/readings', () => {
  125. readingsRequestCount += 1;
  126. return HttpResponse.json([mockReading]);
  127. }),
  128. );
  129. });
  130. it('does not refetch a location Inventory already loaded when Settings opens next', async () => {
  131. const user = userEvent.setup();
  132. render(
  133. <SharedProviders client={client}>
  134. <InventoryPageRouter />
  135. </SharedProviders>
  136. );
  137. await user.click(await screen.findByText('Cards'));
  138. await waitFor(() => expect(screen.getByText('24.50 °C')).toBeInTheDocument());
  139. expect(readingsRequestCount).toBe(1);
  140. cleanup();
  141. render(
  142. <SharedProviders client={client}>
  143. <SettingsPage />
  144. </SharedProviders>
  145. );
  146. await user.click(await screen.findByText('Sensors'));
  147. await screen.findByText('sensor.drybox_1_temperature');
  148. // Give a stray refetch a moment to land before asserting its absence —
  149. // if the keys still mismatched, this is where the second request fires.
  150. await new Promise((r) => setTimeout(r, 50));
  151. expect(readingsRequestCount).toBe(1);
  152. });
  153. });