InventoryPageSpoolmanLocation.test.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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. // Full settings shape — pattern matches InventoryPageLowStock.test.tsx.
  16. const mockSettings = {
  17. auto_archive: true,
  18. save_thumbnails: true,
  19. capture_finish_photo: true,
  20. default_filament_cost: 25.0,
  21. currency: 'USD',
  22. energy_cost_per_kwh: 0.15,
  23. energy_tracking_mode: 'total',
  24. spoolman_enabled: false,
  25. spoolman_url: '',
  26. spoolman_sync_mode: 'auto',
  27. spoolman_disable_weight_sync: false,
  28. spoolman_report_partial_usage: true,
  29. check_updates: true,
  30. check_printer_firmware: true,
  31. include_beta_updates: false,
  32. language: 'en',
  33. notification_language: 'en',
  34. bed_cooled_threshold: 35,
  35. ams_humidity_good: 40,
  36. ams_humidity_fair: 60,
  37. ams_temp_good: 28,
  38. ams_temp_fair: 35,
  39. ams_history_retention_days: 30,
  40. per_printer_mapping_expanded: false,
  41. date_format: 'system',
  42. time_format: 'system',
  43. default_printer_id: null,
  44. virtual_printer_enabled: false,
  45. virtual_printer_access_code: '',
  46. virtual_printer_mode: 'immediate',
  47. dark_style: 'classic',
  48. dark_background: 'neutral',
  49. dark_accent: 'green',
  50. light_style: 'classic',
  51. light_background: 'neutral',
  52. light_accent: 'green',
  53. ftp_retry_enabled: true,
  54. ftp_retry_count: 3,
  55. ftp_retry_delay: 2,
  56. ftp_timeout: 30,
  57. mqtt_enabled: false,
  58. mqtt_broker: '',
  59. mqtt_port: 1883,
  60. mqtt_username: '',
  61. mqtt_password: '',
  62. mqtt_topic_prefix: 'bambuddy',
  63. mqtt_use_tls: false,
  64. external_url: '',
  65. ha_enabled: false,
  66. ha_url: '',
  67. ha_token: '',
  68. ha_url_from_env: false,
  69. ha_token_from_env: false,
  70. ha_env_managed: false,
  71. library_archive_mode: 'ask',
  72. library_disk_warning_gb: 5.0,
  73. camera_view_mode: 'window',
  74. preferred_slicer: 'bambu_studio',
  75. prometheus_enabled: false,
  76. prometheus_token: '',
  77. low_stock_threshold: 20.0,
  78. };
  79. const mockSpoolmanSpool = {
  80. id: 216,
  81. material: 'PLA',
  82. subtype: null,
  83. brand: 'Bambu Lab',
  84. color_name: 'Orange',
  85. rgba: 'FF8800FF',
  86. label_weight: 1000,
  87. core_weight: 250,
  88. weight_used: 200,
  89. slicer_filament: null,
  90. slicer_filament_name: null,
  91. nozzle_temp_min: null,
  92. nozzle_temp_max: null,
  93. note: null,
  94. added_full: null,
  95. last_used: null,
  96. encode_time: null,
  97. tag_uid: null,
  98. tray_uuid: null,
  99. data_origin: 'spoolman',
  100. tag_type: 'spoolman',
  101. archived_at: null,
  102. created_at: '2025-01-01T00:00:00Z',
  103. updated_at: '2025-01-01T00:00:00Z',
  104. k_profiles: [],
  105. cost_per_kg: null,
  106. last_scale_weight: null,
  107. last_weighed_at: null,
  108. storage_location: 'IKEA Regal',
  109. };
  110. describe('InventoryPage - LOCATION column (Spoolman mode)', () => {
  111. beforeEach(() => {
  112. localStorage.clear();
  113. server.use(
  114. http.get('/api/v1/settings/', () => HttpResponse.json(mockSettings)),
  115. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  116. http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
  117. );
  118. });
  119. it('shows AMS slot in LOCATION column for spoolman spool with assignment', async () => {
  120. server.use(
  121. http.get('/api/v1/settings/spoolman', () =>
  122. HttpResponse.json({
  123. spoolman_enabled: 'true',
  124. spoolman_url: 'http://localhost:7912',
  125. })
  126. ),
  127. http.get('/api/v1/spoolman/inventory/spools', () =>
  128. HttpResponse.json([mockSpoolmanSpool])
  129. ),
  130. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  131. HttpResponse.json([
  132. {
  133. printer_id: 1,
  134. printer_name: 'Sully',
  135. ams_id: 0,
  136. tray_id: 2,
  137. spoolman_spool_id: 216,
  138. ams_label: null,
  139. },
  140. ])
  141. ),
  142. );
  143. const { container } = render(<InventoryPageRouter />);
  144. // LOCATION cell renders "{printerLabel} {slotLabel}" via JSX template,
  145. // which splits into separate text nodes. Use innerHTML / textContent inspection.
  146. // formatSlotLabel(0, 2, false, false) => "A3"
  147. await waitFor(() => {
  148. expect(container.textContent).toContain('Sully');
  149. expect(container.textContent).toContain('A3');
  150. });
  151. });
  152. it('shows "-" in LOCATION column when spoolman spool has no slot assignment', async () => {
  153. server.use(
  154. http.get('/api/v1/settings/spoolman', () =>
  155. HttpResponse.json({
  156. spoolman_enabled: 'true',
  157. spoolman_url: 'http://localhost:7912',
  158. })
  159. ),
  160. http.get('/api/v1/spoolman/inventory/spools', () =>
  161. HttpResponse.json([mockSpoolmanSpool])
  162. ),
  163. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  164. HttpResponse.json([])
  165. ),
  166. );
  167. const { container } = render(<InventoryPageRouter />);
  168. await waitFor(() => {
  169. // Spool row is rendered — brand "Bambu Lab" appears in the table somewhere.
  170. expect(container.textContent).toContain('Bambu Lab');
  171. });
  172. // LOCATION cell shows "-" (there may be other "-" cells too — at least one expected).
  173. const dashCells = screen.getAllByText('-');
  174. expect(dashCells.length).toBeGreaterThan(0);
  175. });
  176. it('does not call /spoolman/inventory/slot-assignments/all in local mode', async () => {
  177. let slotEndpointCalled = false;
  178. server.use(
  179. http.get('/api/v1/settings/spoolman', () =>
  180. HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '' })
  181. ),
  182. http.get('/api/v1/inventory/spools', () => HttpResponse.json([])),
  183. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => {
  184. slotEndpointCalled = true;
  185. return HttpResponse.json([]);
  186. }),
  187. );
  188. render(<InventoryPageRouter />);
  189. // Wait for the page to settle by checking a stable element from the stat cards.
  190. await waitFor(() => {
  191. expect(screen.getByText(/total inventory/i)).toBeInTheDocument();
  192. });
  193. expect(slotEndpointCalled).toBe(false);
  194. });
  195. it('counts spoolman slot assignments in the IN PRINTER stat card', async () => {
  196. server.use(
  197. http.get('/api/v1/settings/spoolman', () =>
  198. HttpResponse.json({
  199. spoolman_enabled: 'true',
  200. spoolman_url: 'http://localhost:7912',
  201. })
  202. ),
  203. http.get('/api/v1/spoolman/inventory/spools', () =>
  204. HttpResponse.json([mockSpoolmanSpool])
  205. ),
  206. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  207. HttpResponse.json([
  208. {
  209. printer_id: 1,
  210. printer_name: 'Sully',
  211. ams_id: 0,
  212. tray_id: 2,
  213. spoolman_spool_id: 216,
  214. ams_label: null,
  215. },
  216. ])
  217. ),
  218. );
  219. render(<InventoryPageRouter />);
  220. // Find the "IN PRINTER" stat card by its label, then assert the count "1"
  221. // appears within the same stat-card div. This verifies the inPrinterCount
  222. // sums Spoolman slot assignments (was 0 before Phase 8).
  223. await waitFor(() => {
  224. const label = screen.getByText(/^in printer$/i);
  225. const card = label.closest('div.bg-bambu-dark-secondary');
  226. expect(card).not.toBeNull();
  227. expect(within(card as HTMLElement).getByText('1')).toBeInTheDocument();
  228. });
  229. });
  230. it('local SpoolAssignment wins over Spoolman slot assignment on id collision', async () => {
  231. // Both endpoints return an entry with the same numeric id (216). The local
  232. // /inventory/assignments source must win — printer_name "LocalPrinter" and
  233. // slot "B1" (formatSlotLabel(1, 0, false, false)) — and the Spoolman entry
  234. // ("SpoolmanPrinter" / "C4") must NOT appear.
  235. server.use(
  236. http.get('/api/v1/settings/spoolman', () =>
  237. HttpResponse.json({
  238. spoolman_enabled: 'true',
  239. spoolman_url: 'http://localhost:7912',
  240. })
  241. ),
  242. http.get('/api/v1/spoolman/inventory/spools', () =>
  243. HttpResponse.json([mockSpoolmanSpool])
  244. ),
  245. http.get('/api/v1/inventory/assignments', () =>
  246. HttpResponse.json([
  247. {
  248. id: 99,
  249. spool_id: 216,
  250. printer_id: 7,
  251. printer_name: 'LocalPrinter',
  252. ams_id: 1,
  253. tray_id: 0,
  254. ams_label: null,
  255. created_at: '2025-01-01T00:00:00Z',
  256. },
  257. ])
  258. ),
  259. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () =>
  260. HttpResponse.json([
  261. {
  262. printer_id: 8,
  263. printer_name: 'SpoolmanPrinter',
  264. ams_id: 2,
  265. tray_id: 3,
  266. spoolman_spool_id: 216,
  267. ams_label: null,
  268. },
  269. ])
  270. ),
  271. );
  272. const { container } = render(<InventoryPageRouter />);
  273. await waitFor(() => {
  274. // Local printer wins
  275. expect(container.textContent).toContain('LocalPrinter');
  276. expect(container.textContent).toContain('B1');
  277. });
  278. expect(container.textContent).not.toContain('SpoolmanPrinter');
  279. expect(container.textContent).not.toContain('C4');
  280. });
  281. });