AssignSpoolModal.test.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { screen, waitFor } from '@testing-library/react';
  3. import { render } from '../utils';
  4. import { AssignSpoolModal } from '../../components/AssignSpoolModal';
  5. import { api } from '../../api/client';
  6. vi.mock('../../api/client', () => ({
  7. api: {
  8. getSpools: vi.fn(),
  9. getAssignments: vi.fn(),
  10. assignSpool: vi.fn(),
  11. assignSpoolmanSlot: vi.fn(),
  12. getSpoolmanInventorySpools: vi.fn(),
  13. getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
  14. getSettings: vi.fn().mockResolvedValue({}),
  15. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  16. refreshPrinterStatus: vi.fn().mockResolvedValue({ status: 'ok' }),
  17. },
  18. }));
  19. const mockShowToast = vi.fn();
  20. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  21. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  22. return {
  23. ...actual,
  24. useToast: () => ({ showToast: mockShowToast }),
  25. };
  26. });
  27. const defaultProps = {
  28. isOpen: true,
  29. onClose: vi.fn(),
  30. printerId: 1,
  31. amsId: 0,
  32. trayId: 0,
  33. trayInfo: { type: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' },
  34. };
  35. const manualSpool = {
  36. id: 1,
  37. material: 'PLA',
  38. subtype: 'Basic',
  39. brand: 'Polymaker',
  40. color_name: 'Red',
  41. rgba: 'FF0000FF',
  42. label_weight: 1000,
  43. weight_used: 0,
  44. tag_uid: null,
  45. tray_uuid: null,
  46. slicer_filament_name: 'PLA',
  47. };
  48. const blSpool = {
  49. id: 2,
  50. material: 'PLA',
  51. subtype: 'Basic',
  52. brand: 'Bambu',
  53. color_name: 'Jade White',
  54. rgba: 'FFFFFFFE',
  55. label_weight: 1000,
  56. weight_used: 50,
  57. tag_uid: '05CC1E0F00000100',
  58. tray_uuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  59. slicer_filament_name: 'PLA',
  60. };
  61. const anotherManualSpool = {
  62. id: 3,
  63. material: 'PLA',
  64. subtype: 'HF',
  65. brand: 'Overture',
  66. color_name: 'Black',
  67. rgba: '000000FF',
  68. label_weight: 1000,
  69. weight_used: 200,
  70. tag_uid: null,
  71. tray_uuid: null,
  72. slicer_filament_name: 'PLA',
  73. };
  74. describe('AssignSpoolModal', () => {
  75. beforeEach(() => {
  76. vi.clearAllMocks();
  77. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, blSpool, anotherManualSpool]);
  78. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  79. });
  80. it('renders nothing when closed', () => {
  81. render(<AssignSpoolModal {...defaultProps} isOpen={false} />);
  82. expect(screen.queryByText('Assign Spool')).not.toBeInTheDocument();
  83. });
  84. // Inverted from the original "filters out BL spools" expectation in #1133.
  85. // Bambu Lab spools (tag_uid + tray_uuid populated by SpoolBuddy NFC scan or
  86. // auto-creation) used to be hidden from this picker, blocking the workflow
  87. // where a user has a BL spool in inventory but doesn't want to scan it via
  88. // SpoolBuddy each time and just wants to pick it from the list. The picker
  89. // now lists every spool that isn't already assigned to another slot.
  90. it('lists Bambu Lab spools (with tag_uid/tray_uuid) alongside manual ones (#1133)', async () => {
  91. render(<AssignSpoolModal {...defaultProps} />);
  92. await waitFor(() => {
  93. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  94. });
  95. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  96. expect(screen.getByText(/Overture/)).toBeInTheDocument();
  97. // The previously-excluded BL spool is now visible.
  98. expect(screen.getByText(/Jade White/)).toBeInTheDocument();
  99. });
  100. it('filters out spools already assigned to other slots', async () => {
  101. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  102. { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
  103. ]);
  104. render(<AssignSpoolModal {...defaultProps} />);
  105. await waitFor(() => {
  106. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  107. });
  108. // Spool 1 (not assigned) should be visible
  109. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  110. // Spool 3 (assigned to another slot) should NOT be visible
  111. expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
  112. });
  113. it('keeps spool visible if assigned to the current slot', async () => {
  114. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  115. { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
  116. ]);
  117. render(<AssignSpoolModal {...defaultProps} />);
  118. await waitFor(() => {
  119. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  120. });
  121. // Spool 1 (assigned to current slot) should still be visible for re-assignment
  122. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  123. });
  124. // Empty-state premise reworked for #1133: BL spools no longer trigger
  125. // the empty state by virtue of being BL, so we exercise the only
  126. // remaining trigger — every spool already taken by another slot.
  127. it('shows noAvailableSpools message when every spool is already assigned elsewhere', async () => {
  128. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  129. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  130. // manualSpool (id=1) is taken by a different (printer/ams/tray) tuple,
  131. // so it must be filtered out of THIS slot's picker.
  132. { id: 99, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 1 },
  133. ]);
  134. render(<AssignSpoolModal {...defaultProps} />);
  135. await waitFor(() => {
  136. expect(screen.getByText(/No spools available/i)).toBeInTheDocument();
  137. });
  138. });
  139. // The toggle's label says "Show all spools" but originally only bypassed
  140. // material/profile filtering — spools assigned elsewhere stayed hidden
  141. // even with the toggle on. That made it impossible to recover from the
  142. // case where MQTT auto-reassignment beat a manual unassign by a few
  143. // milliseconds, leaving the just-freed spool in another slot's
  144. // assignment row and out of reach of this picker. With the toggle on,
  145. // every spool is now listed regardless of where it's currently assigned.
  146. it('lists spools assigned to other slots when "Show all spools" toggle is enabled', async () => {
  147. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, anotherManualSpool]);
  148. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  149. // anotherManualSpool (id=3) is taken by a different slot.
  150. { id: 99, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 },
  151. ]);
  152. render(<AssignSpoolModal {...defaultProps} />);
  153. // Default state: spool 3 is hidden because it's assigned elsewhere.
  154. await waitFor(() => {
  155. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  156. });
  157. expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
  158. // Flip the toggle — both spools must now appear, including the one
  159. // currently assigned to the other slot.
  160. const toggle = screen.getByLabelText(/show all spools/i);
  161. toggle.click();
  162. await waitFor(() => {
  163. expect(screen.getByText(/Overture/)).toBeInTheDocument();
  164. });
  165. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  166. });
  167. it('lists spool with no slicer profile when material matches the tray (#1047)', async () => {
  168. const spoolWithoutSlicerProfile = {
  169. id: 10,
  170. material: 'PLA',
  171. subtype: 'Basic',
  172. brand: 'Devil Design',
  173. color_name: 'Red',
  174. rgba: 'FF0000FF',
  175. label_weight: 1000,
  176. weight_used: 0,
  177. tag_uid: null,
  178. tray_uuid: null,
  179. slicer_filament_name: null,
  180. slicer_filament: null,
  181. };
  182. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolWithoutSlicerProfile]);
  183. render(
  184. <AssignSpoolModal
  185. {...defaultProps}
  186. trayInfo={{
  187. type: 'PLA',
  188. material: 'PLA',
  189. profile: 'Devil Design PLA Basic',
  190. color: 'FF0000',
  191. location: 'AMS 1 - Slot 1',
  192. }}
  193. />
  194. );
  195. await waitFor(() => {
  196. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  197. });
  198. });
  199. it('lists spool with shorter material when tray advertises a qualified variant (#1047)', async () => {
  200. // Spool.material = "PLA", tray material = "PLA Basic" — partial match in either direction.
  201. const shortMaterialSpool = {
  202. id: 11,
  203. material: 'PLA',
  204. subtype: 'Basic',
  205. brand: 'Devil Design',
  206. color_name: 'Red',
  207. rgba: 'FF0000FF',
  208. label_weight: 1000,
  209. weight_used: 0,
  210. tag_uid: null,
  211. tray_uuid: null,
  212. slicer_filament_name: null,
  213. slicer_filament: null,
  214. };
  215. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([shortMaterialSpool]);
  216. render(
  217. <AssignSpoolModal
  218. {...defaultProps}
  219. trayInfo={{
  220. type: 'PLA Basic',
  221. material: 'PLA Basic',
  222. color: 'FF0000',
  223. location: 'AMS 1 - Slot 1',
  224. }}
  225. />
  226. );
  227. await waitFor(() => {
  228. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  229. });
  230. });
  231. it('lists spool whose slicer profile has an @printer qualifier that strips to the tray profile (#1047)', async () => {
  232. const qualifiedProfileSpool = {
  233. id: 12,
  234. material: 'PLA',
  235. subtype: 'Basic',
  236. brand: 'Devil Design',
  237. color_name: 'Red',
  238. rgba: 'FF0000FF',
  239. label_weight: 1000,
  240. weight_used: 0,
  241. tag_uid: null,
  242. tray_uuid: null,
  243. slicer_filament_name: 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)',
  244. };
  245. // Use a non-matching material to force the filter to rely on the profile path only.
  246. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([qualifiedProfileSpool]);
  247. render(
  248. <AssignSpoolModal
  249. {...defaultProps}
  250. trayInfo={{
  251. type: 'ABS',
  252. material: 'ABS',
  253. profile: 'Devil Design PLA Basic',
  254. color: 'FF0000',
  255. location: 'AMS 1 - Slot 1',
  256. }}
  257. />
  258. );
  259. await waitFor(() => {
  260. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  261. });
  262. });
  263. it('nudges the printer to republish after successful assignment (#1414)', async () => {
  264. // The backend's assign-spool path issues an MQTT command, but firmware
  265. // (esp. A1 mini external slots and any non-RFID assignment) doesn't
  266. // always echo the new tray state back on its own — the printer card
  267. // then sits on stale data until the user hits Force-refresh. Modal
  268. // calls refreshPrinterStatus to issue a pushall so the printer
  269. // republishes state, mirroring the Force-refresh button.
  270. const { default: userEvent } = await import('@testing-library/user-event');
  271. const user = userEvent.setup();
  272. // Tray material matches the spool to skip the mismatch confirm dialog.
  273. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  274. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  275. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  276. });
  277. render(
  278. <AssignSpoolModal
  279. {...defaultProps}
  280. printerId={7}
  281. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  282. />
  283. );
  284. await waitFor(() => {
  285. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  286. });
  287. await user.click(screen.getByText(/Polymaker/));
  288. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  289. await waitFor(() => {
  290. expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
  291. });
  292. });
  293. // #1680: backend returns `pending_config=true` when the AMS slot was empty
  294. // at assign time (firmware drops the MQTT push silently; on_ams_change
  295. // re-fires the config when filament is detected). The toast MUST reflect
  296. // that the slot hasn't been configured yet — saying "AMS slot configured"
  297. // reads as a lie and is what the reporter hit.
  298. it('shows the pending-insert toast when backend returns pending_config=true (#1680)', async () => {
  299. const { default: userEvent } = await import('@testing-library/user-event');
  300. const user = userEvent.setup();
  301. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  302. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  303. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  304. pending_config: true,
  305. });
  306. render(
  307. <AssignSpoolModal
  308. {...defaultProps}
  309. printerId={7}
  310. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  311. />
  312. );
  313. await waitFor(() => {
  314. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  315. });
  316. await user.click(screen.getByText(/Polymaker/));
  317. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  318. await waitFor(() => {
  319. expect(mockShowToast).toHaveBeenCalledWith(
  320. expect.stringContaining('Slot will configure when you insert the spool'),
  321. 'success',
  322. );
  323. });
  324. // And the "AMS slot configured" toast must NOT also have fired.
  325. expect(mockShowToast).not.toHaveBeenCalledWith(
  326. expect.stringContaining('AMS slot configured'),
  327. expect.anything(),
  328. );
  329. });
  330. // Counterpart guard: when the backend did configure the slot
  331. // (pending_config=false or absent), the existing success toast still
  332. // fires. Without this regression test, the #1680 fix could silently
  333. // overshoot and label every assign as pending.
  334. it('shows the configured toast when backend returns pending_config=false (#1680)', async () => {
  335. const { default: userEvent } = await import('@testing-library/user-event');
  336. const user = userEvent.setup();
  337. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  338. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  339. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  340. pending_config: false,
  341. });
  342. render(
  343. <AssignSpoolModal
  344. {...defaultProps}
  345. printerId={7}
  346. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  347. />
  348. );
  349. await waitFor(() => {
  350. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  351. });
  352. await user.click(screen.getByText(/Polymaker/));
  353. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  354. await waitFor(() => {
  355. expect(mockShowToast).toHaveBeenCalledWith(
  356. expect.stringContaining('AMS slot configured'),
  357. 'success',
  358. );
  359. });
  360. expect(mockShowToast).not.toHaveBeenCalledWith(
  361. expect.stringContaining('Slot will configure when you insert'),
  362. expect.anything(),
  363. );
  364. });
  365. });
  366. describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
  367. const spoolmanSpool = {
  368. id: 200,
  369. material: 'PETG',
  370. subtype: 'HF',
  371. brand: 'Bambu Lab',
  372. color_name: 'Blue',
  373. rgba: '0000FFFF',
  374. label_weight: 1000,
  375. weight_used: 0,
  376. tag_uid: null,
  377. tray_uuid: null,
  378. archived_at: null,
  379. };
  380. beforeEach(() => {
  381. vi.clearAllMocks();
  382. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  383. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  384. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool]);
  385. });
  386. it('shows Spoolman spool section when spoolmanEnabled=true', async () => {
  387. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  388. await waitFor(() => {
  389. // Spoolman spool brand should appear in the modal
  390. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  391. });
  392. expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
  393. });
  394. it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
  395. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled={false} />);
  396. // Give the component time to settle
  397. await waitFor(() => {
  398. expect(api.getSpools).toHaveBeenCalled();
  399. });
  400. expect(api.getSpoolmanInventorySpools).not.toHaveBeenCalled();
  401. });
  402. it('hides local spool list when spoolmanEnabled=true (Bug #5)', async () => {
  403. // Even when local spools exist, they must not appear in Spoolman mode.
  404. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  405. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  406. await waitFor(() => {
  407. // Spoolman spool is shown
  408. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  409. });
  410. // Local spool (Polymaker) must NOT appear in Spoolman mode
  411. expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
  412. });
  413. it('hides archived Spoolman spools', async () => {
  414. const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
  415. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
  416. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  417. await waitFor(() => {
  418. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  419. });
  420. // Archived spool brand must NOT appear
  421. expect(screen.queryByText(/Prusa/)).not.toBeInTheDocument();
  422. });
  423. });