ForecastPanelDeltaRate.test.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. /**
  2. * The forecast's rate when there is not enough usage history (#3407): each
  3. * spool's grams spread over the time it was in use, counted over the last 90
  4. * days. Spoolman mode always used it, and it counted shelf time as use.
  5. */
  6. import { describe, it, expect, vi, beforeEach } from 'vitest';
  7. import { screen, fireEvent } from '@testing-library/react';
  8. import { render } from '../utils';
  9. import { ForecastPanel } from '../../components/ForecastPanel';
  10. import { api } from '../../api/client';
  11. import type { InventorySpool, SpoolUsageRecord } from '../../api/client';
  12. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  13. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  14. return {
  15. ...actual,
  16. useAuth: () => ({ hasPermission: () => true, hasAnyPermission: () => true, authEnabled: false, user: null }),
  17. };
  18. });
  19. vi.mock('../../api/client', async (importOriginal) => {
  20. const actual = await importOriginal<typeof import('../../api/client')>();
  21. return {
  22. ...actual,
  23. api: {
  24. ...actual.api,
  25. getSettings: vi.fn(),
  26. getUiSettings: vi.fn(),
  27. getAuthStatus: vi.fn(),
  28. getSkuSettings: vi.fn(),
  29. getAllUsageHistory: vi.fn(),
  30. getShoppingList: vi.fn(),
  31. addToShoppingList: vi.fn(),
  32. removeFromShoppingList: vi.fn(),
  33. clearShoppingList: vi.fn(),
  34. updateShoppingListStatus: vi.fn(),
  35. bulkCreateSpools: vi.fn(),
  36. upsertSkuSettings: vi.fn(),
  37. updateSettings: vi.fn(),
  38. },
  39. };
  40. });
  41. const mocked = api as unknown as Record<string, ReturnType<typeof vi.fn>>;
  42. const spool = (id: number, over: Partial<InventorySpool> = {}): InventorySpool => ({
  43. id,
  44. material: 'PLA',
  45. subtype: 'Matte',
  46. brand: 'Polymaker',
  47. color_name: 'Red',
  48. rgba: 'FF0000FF',
  49. label_weight: 750,
  50. core_weight: 250,
  51. core_weight_catalog_id: null,
  52. weight_used: 100,
  53. slicer_filament: null,
  54. slicer_filament_name: null,
  55. nozzle_temp_min: null,
  56. nozzle_temp_max: null,
  57. note: null,
  58. added_full: null,
  59. last_used: null,
  60. encode_time: null,
  61. tag_uid: null,
  62. tray_uuid: null,
  63. data_origin: 'manual',
  64. tag_type: null,
  65. archived_at: null,
  66. created_at: '2025-01-01T00:00:00Z',
  67. updated_at: '2025-01-01T00:00:00Z',
  68. k_profiles: [],
  69. cost_per_kg: null,
  70. last_scale_weight: null,
  71. last_weighed_at: null,
  72. weight_locked: false,
  73. category: 'Active',
  74. low_stock_threshold_pct: null,
  75. ...over,
  76. });
  77. const DAY = 86400000;
  78. const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
  79. // The reporter's table, all registered 120 days ago: A 07-01..07-19 and B
  80. // 08-28..10-07 used up (archived, dated by the last use as Spoolman's mapping
  81. // does), C in use since 10-07. Same numbers as test_stock_forecast_delta_rate_3407.py.
  82. const sm = (id: number, used: number, first: number, last: number, archived: boolean) =>
  83. spool(id, {
  84. label_weight: 1000,
  85. weight_used: used,
  86. created_at: ago(120),
  87. first_used: ago(first),
  88. last_used: ago(last),
  89. archived_at: archived ? ago(last) : null,
  90. });
  91. const A = sm(1, 1000, 119, 101, true);
  92. const B = sm(2, 1000, 61, 21, true);
  93. const C = sm(3, 100, 21, 2, false);
  94. describe('ForecastPanel: the rate without enough usage history (#3407)', () => {
  95. beforeEach(() => {
  96. vi.clearAllMocks();
  97. mocked.getSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
  98. mocked.getUiSettings.mockResolvedValue({ forecast_global_lead_time_days: 0 });
  99. mocked.getAuthStatus.mockResolvedValue({ auth_enabled: false, requires_setup: false });
  100. mocked.getSkuSettings.mockResolvedValue([]);
  101. mocked.getAllUsageHistory.mockResolvedValue([]);
  102. mocked.getShoppingList.mockResolvedValue([]);
  103. });
  104. it("the reporter's SKU: 1100 g over the last 90 days, not 1100 g over 120", async () => {
  105. render(<ForecastPanel spools={[A, B, C]} spoolmanMode />);
  106. // 1100 / 90 = 12.2; it showed 9.2.
  107. expect(await screen.findByText('12.2g/d')).toBeInTheDocument();
  108. });
  109. it('shelf time before the first use is not use', async () => {
  110. render(<ForecastPanel spools={[C]} spoolmanMode />);
  111. // 100 g since the first use 21 days ago, not since registering 120 days ago.
  112. expect(await screen.findByText('4.8g/d')).toBeInTheDocument();
  113. });
  114. it("a spool's grams count as far as its use falls inside the window", async () => {
  115. // Scenario J of the backend's parity table: 90 of the older spool's 400 g.
  116. const old = spool(1, { label_weight: 1000, weight_used: 400, created_at: ago(400) });
  117. const young = spool(2, { label_weight: 1000, weight_used: 100, created_at: ago(5) });
  118. render(<ForecastPanel spools={[old, young]} />);
  119. expect(await screen.findByText('2.1g/d')).toBeInTheDocument();
  120. });
  121. it('a spool ends at its last use', async () => {
  122. // Used from 150 to 60 days ago: 30 of its 90 days of use, 300 of its 900 g,
  123. // fall inside the window. Same as the backend's test.
  124. render(<ForecastPanel spools={[sm(1, 900, 150, 60, false)]} spoolmanMode />);
  125. expect(await screen.findByText('3.3g/d')).toBeInTheDocument();
  126. });
  127. it('a spool of one print is not dropped', async () => {
  128. // First and last use the same moment: 200 g at once, 10 days ago.
  129. render(<ForecastPanel spools={[sm(1, 200, 10, 10, false)]} spoolmanMode />);
  130. expect(await screen.findByText('20.0g/d')).toBeInTheDocument();
  131. });
  132. it("a built-in spool's first use is its earliest usage record", async () => {
  133. // One day of history: no trend, but it says when the spool came into use.
  134. mocked.getAllUsageHistory.mockResolvedValue([
  135. { id: 1, spool_id: 1, created_at: ago(30), weight_used: 100 } as SpoolUsageRecord,
  136. ]);
  137. render(<ForecastPanel spools={[spool(1, { label_weight: 1000, weight_used: 300, created_at: ago(120) })]} />);
  138. expect(await screen.findByText('10.0g/d')).toBeInTheDocument();
  139. });
  140. it('a spool archived long ago and never used does not stretch the window', async () => {
  141. const never = spool(9, { label_weight: 1000, weight_used: 0, created_at: ago(400), archived_at: ago(400) });
  142. render(<ForecastPanel spools={[never, C]} spoolmanMode />);
  143. // C alone: 100 g over the 21 days since its first use, not over 90.
  144. expect(await screen.findByText('4.8g/d')).toBeInTheDocument();
  145. });
  146. it("an earlier spool's rows under a reused id are not this spool's", async () => {
  147. // Spool 6 registered 15 days ago; rows from 40 and 30 days ago name id 6
  148. // but belong to a spool Spoolman deleted before handing the id on.
  149. const reused = spool(6, { label_weight: 1000, weight_used: 60, created_at: ago(15), first_used: ago(15) });
  150. mocked.getAllUsageHistory.mockResolvedValue(
  151. [40, 30, 10, 5].map(
  152. (d, i) => ({ id: i + 1, spool_id: null, spoolman_spool_id: 6, created_at: ago(d), weight_used: 30 }) as SpoolUsageRecord,
  153. ),
  154. );
  155. render(<ForecastPanel spools={[reused]} spoolmanMode />);
  156. // Only its own two rows: 30 g over the 5 days between them.
  157. expect(await screen.findByText('6.0g/d')).toBeInTheDocument();
  158. });
  159. it('a SKU not used lately shows a rate of 0 without dividing by it', async () => {
  160. mocked.getSkuSettings.mockResolvedValue([
  161. {
  162. material: 'PLA', subtype: 'Matte', brand: 'Polymaker', color_name: 'Red',
  163. lead_time_days: 7, safety_margin_value: 200, safety_margin_unit: 'g', alerts_snoozed: false,
  164. },
  165. ]);
  166. const idle = spool(1, { label_weight: 1000, weight_used: 300, created_at: ago(300), last_used: ago(120) });
  167. render(<ForecastPanel spools={[idle]} />);
  168. fireEvent.click(await screen.findByText('0.0g/d'));
  169. expect(await screen.findByText('200')).toBeInTheDocument();
  170. expect(document.body.textContent).not.toContain('Infinity');
  171. // Nor in the hint behind the info icon.
  172. expect(document.body.innerHTML).not.toContain('Infinity');
  173. });
  174. });