StatsPageMaterialNumbers.test.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /**
  2. * The "By Material Number" widget on the stats dashboard (#2870).
  3. *
  4. * It sits in the same grid as the widgets that follow the dashboard
  5. * timeframe, so its usage half has to follow it too — otherwise it shows
  6. * lifetime totals next to cards headed "Last 30 days".
  7. *
  8. * In Spoolman mode it aggregates the internal spool table, which is empty
  9. * there, while the inventory list does show numbers mapped from Spoolman's
  10. * filament.article_number. A permanently empty card saying "no material
  11. * numbers assigned yet" next to an inventory full of them is worse than no
  12. * card, so the widget is dropped in that mode.
  13. */
  14. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  15. import { screen, waitFor } from '@testing-library/react';
  16. import { render } from '../utils';
  17. import { StatsPage } from '../../pages/StatsPage';
  18. import { http, HttpResponse } from 'msw';
  19. import { server } from '../mocks/server';
  20. const EMPTY_STATS = {
  21. total_prints: 0,
  22. successful_prints: 0,
  23. failed_prints: 0,
  24. cancelled_prints: 0,
  25. total_print_time_hours: 0,
  26. total_filament_grams: 0,
  27. total_cost: 0,
  28. prints_by_filament_type: {},
  29. prints_by_printer: {},
  30. average_time_accuracy: 0,
  31. time_accuracy_by_printer: {},
  32. total_energy_kwh: 0,
  33. total_energy_cost: 0,
  34. };
  35. let materialNumberRequests: URL[] = [];
  36. function setupHandlers(spoolmanEnabled: boolean, spoolmanGate?: Promise<void>) {
  37. server.use(
  38. http.get('/api/v1/archives/stats', () => HttpResponse.json(EMPTY_STATS)),
  39. http.get('/api/v1/printers/', () => HttpResponse.json([])),
  40. http.get('/api/v1/archives/slim', () => HttpResponse.json([])),
  41. http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'USD' })),
  42. http.get('/api/v1/settings/spoolman', async () => {
  43. // A gate lets a test hold the settings response back while the rest of
  44. // the dashboard renders, which is the ordering the widget has to survive.
  45. if (spoolmanGate) await spoolmanGate;
  46. return HttpResponse.json({
  47. spoolman_enabled: spoolmanEnabled ? 'true' : 'false',
  48. spoolman_url: spoolmanEnabled ? 'http://spoolman.local' : '',
  49. });
  50. }),
  51. http.get('/api/v1/archives/analysis/failures', () =>
  52. HttpResponse.json({
  53. period_days: 30,
  54. total_prints: 0,
  55. failed_prints: 0,
  56. failure_rate: 0,
  57. failures_by_reason: {},
  58. failures_by_filament: {},
  59. failures_by_printer: {},
  60. failures_by_hour: {},
  61. recent_failures: [],
  62. trend: [],
  63. })
  64. ),
  65. http.get('/api/v1/inventory/stats/material-numbers', ({ request }) => {
  66. materialNumberRequests.push(new URL(request.url));
  67. return HttpResponse.json([]);
  68. })
  69. );
  70. }
  71. /** The suite stubs localStorage with bare mocks, so feed the timeframe in. */
  72. function withTimeframe(preset: string) {
  73. (localStorage.getItem as ReturnType<typeof vi.fn>).mockImplementation((key: string) =>
  74. key === 'bambusy-stats-timeframe' ? JSON.stringify({ preset }) : null
  75. );
  76. }
  77. describe('StatsPage material-number widget', () => {
  78. beforeEach(() => {
  79. materialNumberRequests = [];
  80. (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
  81. });
  82. afterEach(() => {
  83. (localStorage.getItem as ReturnType<typeof vi.fn>).mockReset();
  84. });
  85. it('asks the endpoint for the dashboard timeframe', async () => {
  86. withTimeframe('last-30');
  87. setupHandlers(false);
  88. render(<StatsPage />);
  89. await waitFor(() => {
  90. expect(screen.getByText('By Material Number')).toBeInTheDocument();
  91. });
  92. await waitFor(() => {
  93. expect(materialNumberRequests.length).toBeGreaterThan(0);
  94. });
  95. const today = new Date();
  96. const from = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - 29));
  97. expect(materialNumberRequests[0].searchParams.get('date_from')).toBe(from.toISOString().split('T')[0]);
  98. expect(materialNumberRequests[0].searchParams.get('date_to')).toBe(today.toISOString().split('T')[0]);
  99. });
  100. it('asks for lifetime totals when the timeframe is all time', async () => {
  101. withTimeframe('all-time');
  102. setupHandlers(false);
  103. render(<StatsPage />);
  104. await waitFor(() => {
  105. expect(materialNumberRequests.length).toBeGreaterThan(0);
  106. });
  107. expect(materialNumberRequests[0].searchParams.get('date_from')).toBeNull();
  108. expect(materialNumberRequests[0].searchParams.get('date_to')).toBeNull();
  109. });
  110. it('drops the widget entirely in Spoolman mode', async () => {
  111. setupHandlers(true);
  112. render(<StatsPage />);
  113. // Wait for the dashboard to be up before asserting on an absence.
  114. await waitFor(() => {
  115. expect(screen.getByText('Statistics')).toBeInTheDocument();
  116. });
  117. await waitFor(() => {
  118. expect(screen.getByText('Filament Trends')).toBeInTheDocument();
  119. });
  120. expect(screen.queryByText('By Material Number')).toBeNull();
  121. expect(materialNumberRequests).toHaveLength(0);
  122. });
  123. // #2870: the mode decision has to wait for the settings response. Deriving
  124. // it from `undefined` treats "not loaded yet" as "internal mode", so a
  125. // Spoolman install flashed the card and fired the aggregate request before
  126. // the setting arrived.
  127. it('holds the widget back until the Spoolman setting has resolved', async () => {
  128. let openGate = () => {};
  129. const gate = new Promise<void>((resolve) => {
  130. openGate = resolve;
  131. });
  132. setupHandlers(true, gate);
  133. render(<StatsPage />);
  134. // The dashboard is fully up on the archive response alone.
  135. await waitFor(() => {
  136. expect(screen.getByText('Filament Trends')).toBeInTheDocument();
  137. });
  138. expect(screen.queryByText('By Material Number')).toBeNull();
  139. expect(materialNumberRequests).toHaveLength(0);
  140. openGate();
  141. await waitFor(() => {
  142. expect(screen.queryByText('By Material Number')).toBeNull();
  143. });
  144. expect(materialNumberRequests).toHaveLength(0);
  145. });
  146. it('shows the widget once the setting says this is not Spoolman mode', async () => {
  147. let openGate = () => {};
  148. const gate = new Promise<void>((resolve) => {
  149. openGate = resolve;
  150. });
  151. setupHandlers(false, gate);
  152. render(<StatsPage />);
  153. await waitFor(() => {
  154. expect(screen.getByText('Filament Trends')).toBeInTheDocument();
  155. });
  156. expect(screen.queryByText('By Material Number')).toBeNull();
  157. openGate();
  158. await waitFor(() => {
  159. expect(screen.getByText('By Material Number')).toBeInTheDocument();
  160. });
  161. await waitFor(() => {
  162. expect(materialNumberRequests.length).toBeGreaterThan(0);
  163. });
  164. });
  165. });