InventoryPageSpoolmanLocation.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. /**
  2. * Tests for LOCATION column in InventoryPage when in Spoolman mode.
  3. *
  4. * Regression test for Phase 8: LOCATION column showed "-" for Spoolman
  5. * spools assigned to AMS slots, because only the local
  6. * /inventory/assignments endpoint was queried — the Spoolman
  7. * /spoolman/inventory/slot-assignments/all endpoint was ignored.
  8. */
  9. import { describe, it, expect, beforeEach } from 'vitest';
  10. import { screen, waitFor, within } from '@testing-library/react';
  11. import { render } from '../utils';
  12. import InventoryPageRouter from '../../pages/InventoryPage';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. import { useQuery } from '@tanstack/react-query';
  16. import { api } from '../../api/client';
  17. // Full settings shape — pattern matches InventoryPageLowStock.test.tsx.
  18. const mockSettings = {
  19. auto_archive: true,
  20. save_thumbnails: true,
  21. capture_finish_photo: true,
  22. default_filament_cost: 25.0,
  23. currency: 'USD',
  24. energy_cost_per_kwh: 0.15,
  25. energy_tracking_mode: 'total',
  26. spoolman_enabled: false,
  27. spoolman_url: '',
  28. spoolman_sync_mode: 'auto',
  29. spoolman_disable_weight_sync: false,
  30. spoolman_report_partial_usage: true,
  31. check_updates: true,
  32. check_printer_firmware: true,
  33. include_beta_updates: false,
  34. language: 'en',
  35. notification_language: 'en',
  36. bed_cooled_threshold: 35,
  37. ams_humidity_good: 40,
  38. ams_humidity_fair: 60,
  39. ams_temp_good: 28,
  40. ams_temp_fair: 35,
  41. ams_history_retention_days: 30,
  42. per_printer_mapping_expanded: false,
  43. date_format: 'system',
  44. time_format: 'system',
  45. default_printer_id: null,
  46. dark_style: 'classic',
  47. dark_background: 'neutral',
  48. dark_accent: 'green',
  49. light_style: 'classic',
  50. light_background: 'neutral',
  51. light_accent: 'green',
  52. ftp_retry_enabled: true,
  53. ftp_retry_count: 3,
  54. ftp_retry_delay: 2,
  55. ftp_timeout: 30,
  56. mqtt_enabled: false,
  57. mqtt_broker: '',
  58. mqtt_port: 1883,
  59. mqtt_username: '',
  60. mqtt_password: '',
  61. mqtt_topic_prefix: 'bambuddy',
  62. mqtt_use_tls: false,
  63. external_url: '',
  64. ha_enabled: false,
  65. ha_url: '',
  66. ha_token: '',
  67. ha_url_from_env: false,
  68. ha_token_from_env: false,
  69. ha_env_managed: false,
  70. library_disk_warning_gb: 5.0,
  71. camera_view_mode: 'window',
  72. preferred_slicer: 'bambu_studio',
  73. prometheus_enabled: false,
  74. prometheus_token: '',
  75. low_stock_threshold: 20.0,
  76. };
  77. const mockSpoolmanSpool = {
  78. id: 216,
  79. material: 'PLA',
  80. subtype: null,
  81. brand: 'Bambu Lab',
  82. color_name: 'Orange',
  83. rgba: 'FF8800FF',
  84. label_weight: 1000,
  85. core_weight: 250,
  86. weight_used: 200,
  87. slicer_filament: null,
  88. slicer_filament_name: null,
  89. nozzle_temp_min: null,
  90. nozzle_temp_max: null,
  91. note: null,
  92. added_full: null,
  93. last_used: null,
  94. encode_time: null,
  95. tag_uid: null,
  96. tray_uuid: null,
  97. data_origin: 'spoolman',
  98. tag_type: 'spoolman',
  99. archived_at: null,
  100. created_at: '2025-01-01T00:00:00Z',
  101. updated_at: '2025-01-01T00:00:00Z',
  102. k_profiles: [],
  103. cost_per_kg: null,
  104. last_scale_weight: null,
  105. last_weighed_at: null,
  106. storage_location: 'IKEA Regal',
  107. };
  108. describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
  109. beforeEach(() => {
  110. localStorage.clear();
  111. server.use(
  112. http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
  113. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  114. http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
  115. );
  116. });
  117. it('shows AMS slot in LOCATION column for spoolman spool with assignment', async () => {
  118. server.use(
  119. http.get('/api/v1/settings/spoolman', () =>
  120. HttpResponse.json({
  121. spoolman_enabled: 'true',
  122. spoolman_url: 'http://localhost:7912',
  123. })
  124. ),
  125. http.get('/api/v1/spoolman/inventory/spools', () =>
  126. HttpResponse.json([mockSpoolmanSpool])
  127. ),
  128. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  129. HttpResponse.json([
  130. {
  131. printer_id: 1,
  132. printer_name: 'Sully',
  133. ams_id: 0,
  134. tray_id: 2,
  135. spoolman_spool_id: 216,
  136. ams_label: null,
  137. },
  138. ])
  139. ),
  140. );
  141. const { container } = render(<InventoryPageRouter />);
  142. // LOCATION cell renders "{printerLabel} {slotLabel}" via JSX template,
  143. // which splits into separate text nodes. Use innerHTML / textContent inspection.
  144. // formatSlotLabel(0, 2, false, false) => "A3"
  145. await waitFor(() => {
  146. expect(container.textContent).toContain('Sully');
  147. expect(container.textContent).toContain('A3');
  148. });
  149. });
  150. it('labels an H3C slot 3H-n and a per-hotend external input Ext-n', async () => {
  151. server.use(
  152. http.get('/api/v1/settings/spoolman', () =>
  153. HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
  154. ),
  155. http.get('/api/v1/spoolman/inventory/spools', () =>
  156. HttpResponse.json([mockSpoolmanSpool, { ...mockSpoolmanSpool, id: 217, color_name: 'Blue', rgba: '0000FFFF' }])
  157. ),
  158. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  159. HttpResponse.json([
  160. // AMS 3H slot 7 is stored as (32, 6); input 250 (hotend 5) as (255, 5).
  161. { printer_id: 2, printer_name: 'Workshop', ams_id: 32, tray_id: 6, spoolman_spool_id: 216, ams_label: null, printer_model: 'H3C' },
  162. { printer_id: 2, printer_name: 'Workshop', ams_id: 255, tray_id: 5, spoolman_spool_id: 217, ams_label: null, printer_model: 'H3C' },
  163. ])
  164. ),
  165. );
  166. const { container } = render(<InventoryPageRouter />);
  167. await waitFor(() => {
  168. expect(container.textContent).toContain('Workshop 3H-7');
  169. expect(container.textContent).toContain('Workshop Ext-6');
  170. });
  171. });
  172. it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => {
  173. server.use(
  174. http.get('/api/v1/settings/spoolman', () =>
  175. HttpResponse.json({
  176. spoolman_enabled: 'true',
  177. spoolman_url: 'http://localhost:7912',
  178. })
  179. ),
  180. http.get('/api/v1/spoolman/inventory/spools', () =>
  181. HttpResponse.json([mockSpoolmanSpool])
  182. ),
  183. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  184. HttpResponse.json([])
  185. ),
  186. );
  187. const { container } = render(<InventoryPageRouter />);
  188. await waitFor(() => {
  189. // Spool row is rendered — brand "Bambu Lab" appears in the table somewhere.
  190. expect(container.textContent).toContain('Bambu Lab');
  191. });
  192. // LOCATION cell shows "-" (there may be other "-" cells too — at least one expected).
  193. const dashCells = screen.getAllByText('-');
  194. expect(dashCells.length).toBeGreaterThan(0);
  195. });
  196. it('does not call /spoolman/inventory/slot-assignments/all in local mode', async () => {
  197. let slotEndpointCalled = false;
  198. server.use(
  199. http.get('/api/v1/settings/spoolman', () =>
  200. HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
  201. ),
  202. http.get('/api/v1/inventory/spools', () => HttpResponse.json([])),
  203. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => {
  204. slotEndpointCalled = true;
  205. return HttpResponse.json([]);
  206. }),
  207. );
  208. render(<InventoryPageRouter />);
  209. // Wait for the page to settle by checking a stable element from the stat cards.
  210. await waitFor(() => {
  211. expect(screen.getByText(/total inventory/i)).toBeInTheDocument();
  212. });
  213. expect(slotEndpointCalled).toBe(false);
  214. });
  215. it('built-in rows another page put in the shared cache stay off a Spoolman spool (#3430)', async () => {
  216. // The printer page fetches ['spool-assignments'] in either mode; a
  217. // disabled query still hands out what is cached under its key.
  218. function OtherPage() {
  219. useQuery({ queryKey: ['spool-assignments'], queryFn: () => api.getAssignments() });
  220. return null;
  221. }
  222. server.use(
  223. http.get('/api/v1/settings/spoolman', () =>
  224. HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
  225. ),
  226. http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])),
  227. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  228. http.get('/api/v1/inventory/assignments', () =>
  229. HttpResponse.json([
  230. { id: 99, spool_id: 216, printer_id: 7, printer_name: 'LocalPrinter', ams_id: 0, tray_id: 0, ams_label: null, created_at: '2025-01-01T00:00:00Z' },
  231. ])
  232. ),
  233. );
  234. const { container } = render(
  235. <>
  236. <OtherPage />
  237. <InventoryPageRouter />
  238. </>
  239. );
  240. await waitFor(() => expect(container.textContent).toContain('Bambu Lab'));
  241. await new Promise((r) => setTimeout(r, 50));
  242. expect(container.textContent).not.toContain('LocalPrinter');
  243. });
  244. it('does not fetch built-in assignments in Spoolman mode', async () => {
  245. let builtInFetched = false;
  246. server.use(
  247. http.get('/api/v1/settings/spoolman', () =>
  248. HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
  249. ),
  250. http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])),
  251. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  252. http.get('/api/v1/inventory/assignments', () => {
  253. builtInFetched = true;
  254. return HttpResponse.json([]);
  255. }),
  256. );
  257. const { container } = render(<InventoryPageRouter />);
  258. await waitFor(() => expect(container.textContent).toContain('Bambu Lab'));
  259. expect(builtInFetched).toBe(false);
  260. });
  261. it('counts spoolman slot assignments in the IN PRINTER stat card', async () => {
  262. server.use(
  263. http.get('/api/v1/settings/spoolman', () =>
  264. HttpResponse.json({
  265. spoolman_enabled: 'true',
  266. spoolman_url: 'http://localhost:7912',
  267. })
  268. ),
  269. http.get('/api/v1/spoolman/inventory/spools', () =>
  270. HttpResponse.json([mockSpoolmanSpool])
  271. ),
  272. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  273. HttpResponse.json([
  274. {
  275. printer_id: 1,
  276. printer_name: 'Sully',
  277. ams_id: 0,
  278. tray_id: 2,
  279. spoolman_spool_id: 216,
  280. ams_label: null,
  281. },
  282. ])
  283. ),
  284. );
  285. render(<InventoryPageRouter />);
  286. // Find the "IN PRINTER" stat card by its label, then assert the count "1"
  287. // appears within the same stat-card div. This verifies the inPrinterCount
  288. // sums Spoolman slot assignments (was 0 before Phase 8).
  289. await waitFor(() => {
  290. const label = screen.getByText(/^in printer$/i);
  291. const card = label.closest('div.bg-bambu-dark-secondary');
  292. expect(card).not.toBeNull();
  293. expect(within(card as HTMLElement).getByText('1')).toBeInTheDocument();
  294. });
  295. });
  296. it('a built-in assignment kept from built-in mode is not shown on a Spoolman spool (#3430)', async () => {
  297. // Since #2812 built-in rows survive a switch to Spoolman. Their spool_id is
  298. // a built-in id, so built-in spool 216 in "LocalPrinter" B1 must not label
  299. // Spoolman spool 216, which Spoolman has in "SpoolmanPrinter" C4.
  300. server.use(
  301. http.get('/api/v1/settings/spoolman', () =>
  302. HttpResponse.json({
  303. spoolman_enabled: 'true',
  304. spoolman_url: 'http://localhost:7912',
  305. })
  306. ),
  307. http.get('/api/v1/spoolman/inventory/spools', () =>
  308. HttpResponse.json([mockSpoolmanSpool])
  309. ),
  310. http.get('/api/v1/inventory/assignments', () =>
  311. HttpResponse.json([
  312. {
  313. id: 99,
  314. spool_id: 216,
  315. printer_id: 7,
  316. printer_name: 'LocalPrinter',
  317. ams_id: 1,
  318. tray_id: 0,
  319. ams_label: null,
  320. created_at: '2025-01-01T00:00:00Z',
  321. },
  322. ])
  323. ),
  324. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  325. HttpResponse.json([
  326. {
  327. printer_id: 8,
  328. printer_name: 'SpoolmanPrinter',
  329. ams_id: 2,
  330. tray_id: 3,
  331. spoolman_spool_id: 216,
  332. ams_label: null,
  333. },
  334. ])
  335. ),
  336. );
  337. const { container } = render(<InventoryPageRouter />);
  338. await waitFor(() => {
  339. expect(container.textContent).toContain('SpoolmanPrinter');
  340. expect(container.textContent).toContain('C4');
  341. });
  342. expect(container.textContent).not.toContain('LocalPrinter');
  343. });
  344. it('a spool Spoolman has in no slot shows no slot, whatever the built-in table holds (#3430)', async () => {
  345. // The reporter's case: the spool was taken out and Spoolman's row removed,
  346. // but a kept built-in row with the same number still named a slot.
  347. server.use(
  348. http.get('/api/v1/settings/spoolman', () =>
  349. HttpResponse.json({
  350. spoolman_enabled: 'true',
  351. spoolman_url: 'http://localhost:7912',
  352. })
  353. ),
  354. http.get('/api/v1/spoolman/inventory/spools', () =>
  355. HttpResponse.json([mockSpoolmanSpool])
  356. ),
  357. http.get('/api/v1/inventory/assignments', () =>
  358. HttpResponse.json([
  359. {
  360. id: 99,
  361. spool_id: 216,
  362. printer_id: 7,
  363. printer_name: 'LocalPrinter',
  364. ams_id: 0,
  365. tray_id: 0,
  366. ams_label: null,
  367. created_at: '2025-01-01T00:00:00Z',
  368. },
  369. ])
  370. ),
  371. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  372. );
  373. const { container } = render(<InventoryPageRouter />);
  374. await waitFor(() => expect(container.textContent).toContain('Bambu Lab'));
  375. expect(container.textContent).not.toContain('LocalPrinter');
  376. // Nor counted as in a printer.
  377. const label = screen.getByText(/^in printer$/i);
  378. const card = label.closest('div.bg-bambu-dark-secondary');
  379. expect(within(card as HTMLElement).getByText('0')).toBeInTheDocument();
  380. });
  381. });
  382. describe('InventoryPage - Forecast in Spoolman mode (#3406)', () => {
  383. it("the forecast does not read the built-in inventory's usage history", async () => {
  384. localStorage.clear();
  385. // The Spoolman spools' own rows are read (#3407), never the built-in ones.
  386. let builtInUsageRead = false;
  387. let spoolmanUsageRead = false;
  388. server.use(
  389. http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
  390. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  391. http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
  392. http.get('/api/v1/settings/spoolman', () =>
  393. HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://localhost:7912' })
  394. ),
  395. http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([mockSpoolmanSpool])),
  396. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  397. http.get('/api/v1/inventory/usage', ({ request }) => {
  398. if (new URL(request.url).searchParams.get('spoolman') === 'true') spoolmanUsageRead = true;
  399. else builtInUsageRead = true;
  400. return HttpResponse.json([]);
  401. }),
  402. );
  403. const { container } = render(<InventoryPageRouter />);
  404. await waitFor(() => expect(container.textContent).toContain('Bambu Lab'));
  405. screen.getByRole('button', { name: /forecast/i }).click();
  406. // The forecast is up, with the rate from the spool's own consumption.
  407. expect(await screen.findByText('Est.')).toBeInTheDocument();
  408. await waitFor(() => expect(spoolmanUsageRead).toBe(true));
  409. expect(builtInUsageRead).toBe(false);
  410. });
  411. });