AssignSpoolModal.test.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  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('shows each spool ID on the assignment card (#2978)', async () => {
  101. render(<AssignSpoolModal {...defaultProps} />);
  102. await waitFor(() => {
  103. expect(screen.getByText('#1')).toBeInTheDocument();
  104. });
  105. expect(screen.getByText('#2')).toBeInTheDocument();
  106. expect(screen.getByText('#3')).toBeInTheDocument();
  107. });
  108. it('renders a clear tray as the transparency checkerboard, not an invisible circle (#2912)', async () => {
  109. render(
  110. <AssignSpoolModal
  111. {...defaultProps}
  112. trayInfo={{ type: 'PETG', color: '00000000', location: 'AMS 1 - Slot 1' }}
  113. />,
  114. );
  115. await waitFor(() => {
  116. expect(screen.getByText(/PETG/)).toBeInTheDocument();
  117. });
  118. // The tray swatch used to be painted `#00000000` directly, which is a valid
  119. // colour the browser renders as nothing at all — a clear spool showed an
  120. // empty circle. getSwatchStyle draws the checkerboard instead.
  121. const swatches = Array.from(document.querySelectorAll<HTMLElement>('[style]'));
  122. expect(
  123. swatches.some((el) => el.style.backgroundImage.includes('repeating-conic-gradient')),
  124. ).toBe(true);
  125. });
  126. it('filters out spools already assigned to other slots', async () => {
  127. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  128. { id: 1, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 }, // spool 3 assigned to different slot
  129. ]);
  130. render(<AssignSpoolModal {...defaultProps} />);
  131. await waitFor(() => {
  132. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  133. });
  134. // Spool 1 (not assigned) should be visible
  135. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  136. // Spool 3 (assigned to another slot) should NOT be visible
  137. expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
  138. });
  139. it('keeps spool visible if assigned to the current slot', async () => {
  140. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  141. { id: 1, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 0 }, // spool 1 assigned to THIS slot
  142. ]);
  143. render(<AssignSpoolModal {...defaultProps} />);
  144. await waitFor(() => {
  145. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  146. });
  147. // Spool 1 (assigned to current slot) should still be visible for re-assignment
  148. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  149. });
  150. // Empty-state premise reworked for #1133: BL spools no longer trigger
  151. // the empty state by virtue of being BL, so we exercise the only
  152. // remaining trigger — every spool already taken by another slot.
  153. it('shows noAvailableSpools message when every spool is already assigned elsewhere', async () => {
  154. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  155. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  156. // manualSpool (id=1) is taken by a different (printer/ams/tray) tuple,
  157. // so it must be filtered out of THIS slot's picker.
  158. { id: 99, spool_id: 1, printer_id: 1, ams_id: 0, tray_id: 1 },
  159. ]);
  160. render(<AssignSpoolModal {...defaultProps} />);
  161. await waitFor(() => {
  162. expect(screen.getByText(/No spools available/i)).toBeInTheDocument();
  163. });
  164. });
  165. // The toggle's label says "Show all spools" but originally only bypassed
  166. // material/profile filtering — spools assigned elsewhere stayed hidden
  167. // even with the toggle on. That made it impossible to recover from the
  168. // case where MQTT auto-reassignment beat a manual unassign by a few
  169. // milliseconds, leaving the just-freed spool in another slot's
  170. // assignment row and out of reach of this picker. With the toggle on,
  171. // every spool is now listed regardless of where it's currently assigned.
  172. it('lists spools assigned to other slots when "Show all spools" toggle is enabled', async () => {
  173. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool, anotherManualSpool]);
  174. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([
  175. // anotherManualSpool (id=3) is taken by a different slot.
  176. { id: 99, spool_id: 3, printer_id: 1, ams_id: 0, tray_id: 1 },
  177. ]);
  178. render(<AssignSpoolModal {...defaultProps} />);
  179. // Default state: spool 3 is hidden because it's assigned elsewhere.
  180. await waitFor(() => {
  181. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  182. });
  183. expect(screen.queryByText(/Overture/)).not.toBeInTheDocument();
  184. // Flip the toggle — both spools must now appear, including the one
  185. // currently assigned to the other slot.
  186. const toggle = screen.getByLabelText(/show all spools/i);
  187. toggle.click();
  188. await waitFor(() => {
  189. expect(screen.getByText(/Overture/)).toBeInTheDocument();
  190. });
  191. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  192. });
  193. it('lists spool with no slicer profile when material matches the tray (#1047)', async () => {
  194. const spoolWithoutSlicerProfile = {
  195. id: 10,
  196. material: 'PLA',
  197. subtype: 'Basic',
  198. brand: 'Devil Design',
  199. color_name: 'Red',
  200. rgba: 'FF0000FF',
  201. label_weight: 1000,
  202. weight_used: 0,
  203. tag_uid: null,
  204. tray_uuid: null,
  205. slicer_filament_name: null,
  206. slicer_filament: null,
  207. };
  208. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolWithoutSlicerProfile]);
  209. render(
  210. <AssignSpoolModal
  211. {...defaultProps}
  212. trayInfo={{
  213. type: 'PLA',
  214. material: 'PLA',
  215. profile: 'Devil Design PLA Basic',
  216. color: 'FF0000',
  217. location: 'AMS 1 - Slot 1',
  218. }}
  219. />
  220. );
  221. await waitFor(() => {
  222. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  223. });
  224. });
  225. it('lists spool with shorter material when tray advertises a qualified variant (#1047)', async () => {
  226. // Spool.material = "PLA", tray material = "PLA Basic" — partial match in either direction.
  227. const shortMaterialSpool = {
  228. id: 11,
  229. material: 'PLA',
  230. subtype: 'Basic',
  231. brand: 'Devil Design',
  232. color_name: 'Red',
  233. rgba: 'FF0000FF',
  234. label_weight: 1000,
  235. weight_used: 0,
  236. tag_uid: null,
  237. tray_uuid: null,
  238. slicer_filament_name: null,
  239. slicer_filament: null,
  240. };
  241. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([shortMaterialSpool]);
  242. render(
  243. <AssignSpoolModal
  244. {...defaultProps}
  245. trayInfo={{
  246. type: 'PLA Basic',
  247. material: 'PLA Basic',
  248. color: 'FF0000',
  249. location: 'AMS 1 - Slot 1',
  250. }}
  251. />
  252. );
  253. await waitFor(() => {
  254. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  255. });
  256. });
  257. it('lists spool whose slicer profile has an @printer qualifier that strips to the tray profile (#1047)', async () => {
  258. const qualifiedProfileSpool = {
  259. id: 12,
  260. material: 'PLA',
  261. subtype: 'Basic',
  262. brand: 'Devil Design',
  263. color_name: 'Red',
  264. rgba: 'FF0000FF',
  265. label_weight: 1000,
  266. weight_used: 0,
  267. tag_uid: null,
  268. tray_uuid: null,
  269. slicer_filament_name: 'Devil Design PLA Basic @Bambu Lab H2D 0.4 nozzle (Custom)',
  270. };
  271. // Use a non-matching material to force the filter to rely on the profile path only.
  272. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([qualifiedProfileSpool]);
  273. render(
  274. <AssignSpoolModal
  275. {...defaultProps}
  276. trayInfo={{
  277. type: 'ABS',
  278. material: 'ABS',
  279. profile: 'Devil Design PLA Basic',
  280. color: 'FF0000',
  281. location: 'AMS 1 - Slot 1',
  282. }}
  283. />
  284. );
  285. await waitFor(() => {
  286. expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
  287. });
  288. });
  289. it('nudges the printer to republish after successful assignment (#1414)', async () => {
  290. // The backend's assign-spool path issues an MQTT command, but firmware
  291. // (esp. A1 mini external slots and any non-RFID assignment) doesn't
  292. // always echo the new tray state back on its own — the printer card
  293. // then sits on stale data until the user hits Force-refresh. Modal
  294. // calls refreshPrinterStatus to issue a pushall so the printer
  295. // republishes state, mirroring the Force-refresh button.
  296. const { default: userEvent } = await import('@testing-library/user-event');
  297. const user = userEvent.setup();
  298. // Tray material matches the spool to skip the mismatch confirm dialog.
  299. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  300. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  301. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  302. });
  303. render(
  304. <AssignSpoolModal
  305. {...defaultProps}
  306. printerId={7}
  307. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  308. />
  309. );
  310. await waitFor(() => {
  311. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  312. });
  313. await user.click(screen.getByText(/Polymaker/));
  314. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  315. await waitFor(() => {
  316. expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
  317. });
  318. });
  319. // #1680: backend returns `pending_config=true` when the AMS slot was empty
  320. // at assign time (firmware drops the MQTT push silently; on_ams_change
  321. // re-fires the config when filament is detected). The toast MUST reflect
  322. // that the slot hasn't been configured yet — saying "AMS slot configured"
  323. // reads as a lie and is what the reporter hit.
  324. it('shows the pending-insert toast when backend returns pending_config=true (#1680)', async () => {
  325. const { default: userEvent } = await import('@testing-library/user-event');
  326. const user = userEvent.setup();
  327. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  328. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  329. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  330. pending_config: true,
  331. });
  332. render(
  333. <AssignSpoolModal
  334. {...defaultProps}
  335. printerId={7}
  336. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  337. />
  338. );
  339. await waitFor(() => {
  340. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  341. });
  342. await user.click(screen.getByText(/Polymaker/));
  343. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  344. await waitFor(() => {
  345. expect(mockShowToast).toHaveBeenCalledWith(
  346. expect.stringContaining('Slot will configure when you insert the spool'),
  347. 'success',
  348. );
  349. });
  350. // And the "AMS slot configured" toast must NOT also have fired.
  351. expect(mockShowToast).not.toHaveBeenCalledWith(
  352. expect.stringContaining('AMS slot configured'),
  353. expect.anything(),
  354. );
  355. });
  356. // Counterpart guard: when the backend did configure the slot
  357. // (pending_config=false or absent), the existing success toast still
  358. // fires. Without this regression test, the #1680 fix could silently
  359. // overshoot and label every assign as pending.
  360. it('shows the configured toast when backend returns pending_config=false (#1680)', async () => {
  361. const { default: userEvent } = await import('@testing-library/user-event');
  362. const user = userEvent.setup();
  363. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  364. (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
  365. id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
  366. pending_config: false,
  367. });
  368. render(
  369. <AssignSpoolModal
  370. {...defaultProps}
  371. printerId={7}
  372. trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
  373. />
  374. );
  375. await waitFor(() => {
  376. expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
  377. });
  378. await user.click(screen.getByText(/Polymaker/));
  379. await user.click(screen.getByRole('button', { name: /assign spool/i }));
  380. await waitFor(() => {
  381. expect(mockShowToast).toHaveBeenCalledWith(
  382. expect.stringContaining('AMS slot configured'),
  383. 'success',
  384. );
  385. });
  386. expect(mockShowToast).not.toHaveBeenCalledWith(
  387. expect.stringContaining('Slot will configure when you insert'),
  388. expect.anything(),
  389. );
  390. });
  391. });
  392. describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {
  393. const spoolmanSpool = {
  394. id: 200,
  395. material: 'PETG',
  396. subtype: 'HF',
  397. brand: 'Bambu Lab',
  398. color_name: 'Blue',
  399. rgba: '0000FFFF',
  400. label_weight: 1000,
  401. weight_used: 0,
  402. tag_uid: null,
  403. tray_uuid: null,
  404. archived_at: null,
  405. };
  406. beforeEach(() => {
  407. vi.clearAllMocks();
  408. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  409. (api.getAssignments as ReturnType<typeof vi.fn>).mockResolvedValue([]);
  410. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool]);
  411. });
  412. it('shows Spoolman spool section when spoolmanEnabled=true', async () => {
  413. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  414. await waitFor(() => {
  415. // Spoolman spool brand should appear in the modal
  416. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  417. });
  418. expect(api.getSpoolmanInventorySpools).toHaveBeenCalledWith(false);
  419. expect(screen.getByText('#200')).toBeInTheDocument();
  420. });
  421. it('does not fetch Spoolman spools when spoolmanEnabled=false', async () => {
  422. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled={false} />);
  423. // Give the component time to settle
  424. await waitFor(() => {
  425. expect(api.getSpools).toHaveBeenCalled();
  426. });
  427. expect(api.getSpoolmanInventorySpools).not.toHaveBeenCalled();
  428. });
  429. it('hides local spool list when spoolmanEnabled=true (Bug #5)', async () => {
  430. // Even when local spools exist, they must not appear in Spoolman mode.
  431. (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
  432. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  433. await waitFor(() => {
  434. // Spoolman spool is shown
  435. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  436. });
  437. // Local spool (Polymaker) must NOT appear in Spoolman mode
  438. expect(screen.queryByText(/Polymaker/)).not.toBeInTheDocument();
  439. });
  440. it('hides archived Spoolman spools', async () => {
  441. const archivedSpool = { ...spoolmanSpool, id: 201, brand: 'Prusa', archived_at: '2025-01-01T00:00:00Z' };
  442. (api.getSpoolmanInventorySpools as ReturnType<typeof vi.fn>).mockResolvedValue([spoolmanSpool, archivedSpool]);
  443. render(<AssignSpoolModal {...defaultProps} spoolmanEnabled />);
  444. await waitFor(() => {
  445. expect(screen.getByText(/Bambu Lab/)).toBeInTheDocument();
  446. });
  447. // Archived spool brand must NOT appear
  448. expect(screen.queryByText(/Prusa/)).not.toBeInTheDocument();
  449. });
  450. });