LinkSpoolModal.test.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /**
  2. * Tests for the LinkSpoolModal component.
  3. *
  4. * Tests the inventory link-to-spool modal including:
  5. * - Rendering modal with tag/tray info
  6. * - Displaying untagged spools
  7. * - Linking a spool via click
  8. * - Search filtering
  9. */
  10. import { describe, it, expect, vi, beforeEach } from 'vitest';
  11. import { screen, waitFor, fireEvent } from '@testing-library/react';
  12. import { render } from '../utils';
  13. import { LinkSpoolModal } from '../../components/LinkSpoolModal';
  14. // Mock the API client
  15. vi.mock('../../api/client', () => ({
  16. api: {
  17. getUnlinkedSpools: vi.fn(),
  18. linkSpool: vi.fn(),
  19. getSettings: vi.fn().mockResolvedValue({}),
  20. getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
  21. },
  22. }));
  23. // Mock the toast context
  24. const mockShowToast = vi.fn();
  25. vi.mock('../../contexts/ToastContext', async (importOriginal) => {
  26. const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
  27. return {
  28. ...actual,
  29. useToast: () => ({ showToast: mockShowToast }),
  30. };
  31. });
  32. // Import mocked module
  33. import { api } from '../../api/client';
  34. describe('LinkSpoolModal', () => {
  35. const defaultProps = {
  36. isOpen: true,
  37. onClose: vi.fn(),
  38. tagUid: 'ABCD1234',
  39. trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  40. printerId: 1,
  41. amsId: 0,
  42. trayId: 0,
  43. };
  44. const mockSpools = [
  45. {
  46. id: 1,
  47. filament_name: 'Generic PLA Red',
  48. filament_material: 'PLA',
  49. filament_color_hex: 'FF0000',
  50. remaining_weight: 800,
  51. location: null,
  52. },
  53. {
  54. id: 2,
  55. filament_name: 'Bambu PETG Blue',
  56. filament_material: 'PETG',
  57. filament_color_hex: '0000FF',
  58. remaining_weight: 500,
  59. location: null,
  60. },
  61. ];
  62. beforeEach(() => {
  63. vi.clearAllMocks();
  64. vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockSpools);
  65. vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'ok' });
  66. });
  67. describe('rendering', () => {
  68. it('renders modal title', async () => {
  69. render(<LinkSpoolModal {...defaultProps} />);
  70. await waitFor(() => {
  71. expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
  72. });
  73. });
  74. it('displays printer and tray info', async () => {
  75. render(<LinkSpoolModal {...defaultProps} />);
  76. await waitFor(() => {
  77. expect(screen.getByText(/AMS 0 T0/)).toBeInTheDocument();
  78. expect(screen.getByText(/Printer #1/)).toBeInTheDocument();
  79. });
  80. });
  81. it('shows loading state while fetching spools', async () => {
  82. vi.mocked(api.getUnlinkedSpools).mockImplementation(() => new Promise(() => {}));
  83. render(<LinkSpoolModal {...defaultProps} />);
  84. await waitFor(() => {
  85. expect(document.querySelector('.animate-spin')).toBeInTheDocument();
  86. });
  87. });
  88. it('paints a clear spool as the checkerboard, not an invisible swatch', async () => {
  89. // Same swatch bug as the filament picker: this row built its background as
  90. // `#${filament_color_hex}`, so a clear spool — which only reaches the list
  91. // with its alpha intact because of #2912 — rendered as an empty circle.
  92. vi.mocked(api.getUnlinkedSpools).mockResolvedValue([
  93. { ...mockSpools[0], id: 3, filament_name: 'Bambu PLA Clear', filament_color_hex: '00000000' },
  94. ]);
  95. render(<LinkSpoolModal {...defaultProps} />);
  96. await waitFor(() => {
  97. expect(screen.getByText(/Bambu PLA Clear/)).toBeInTheDocument();
  98. });
  99. const swatch = document.querySelector('span.rounded-full') as HTMLElement;
  100. expect(swatch.style.backgroundImage).toContain('repeating-conic-gradient');
  101. expect(swatch.style.backgroundColor).toBe('');
  102. });
  103. it('displays unlinked spools from Spoolman', async () => {
  104. render(<LinkSpoolModal {...defaultProps} />);
  105. await waitFor(() => {
  106. // Should show spools from getUnlinkedSpools
  107. expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
  108. expect(screen.getByText(/Bambu PETG Blue/)).toBeInTheDocument();
  109. });
  110. });
  111. it('does not render when isOpen is false', () => {
  112. render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
  113. expect(screen.queryByRole('heading', { name: /select spool/i })).not.toBeInTheDocument();
  114. });
  115. });
  116. describe('linking', () => {
  117. it('uses trayUuid when linking if present (Bambu spool path)', async () => {
  118. render(<LinkSpoolModal {...defaultProps} />);
  119. await waitFor(() => {
  120. expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
  121. });
  122. fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
  123. await waitFor(() => {
  124. expect(api.linkSpool).toHaveBeenCalledWith(1, {
  125. spoolTag: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  126. printerId: 1,
  127. amsId: 0,
  128. trayId: 0,
  129. });
  130. });
  131. });
  132. it('falls back to tagUid when trayUuid is missing (generic spool path)', async () => {
  133. render(<LinkSpoolModal {...defaultProps} trayUuid="" />);
  134. await waitFor(() => {
  135. expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
  136. });
  137. fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
  138. await waitFor(() => {
  139. expect(api.linkSpool).toHaveBeenCalledWith(1, {
  140. spoolTag: 'ABCD1234',
  141. printerId: 1,
  142. amsId: 0,
  143. trayId: 0,
  144. });
  145. });
  146. });
  147. it('shows success toast and calls onClose', async () => {
  148. render(<LinkSpoolModal {...defaultProps} />);
  149. await waitFor(() => {
  150. expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
  151. });
  152. fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
  153. await waitFor(() => {
  154. expect(mockShowToast).toHaveBeenCalled();
  155. expect(defaultProps.onClose).toHaveBeenCalled();
  156. });
  157. });
  158. it('shows error toast on failure', async () => {
  159. vi.mocked(api.linkSpool).mockRejectedValue(new Error('Link failed'));
  160. render(<LinkSpoolModal {...defaultProps} />);
  161. await waitFor(() => {
  162. expect(screen.getByText(/Generic PLA Red/)).toBeInTheDocument();
  163. });
  164. fireEvent.click(screen.getByText(/Generic PLA Red/).closest('button')!);
  165. await waitFor(() => {
  166. expect(mockShowToast).toHaveBeenCalledWith(
  167. expect.stringContaining('Link failed'),
  168. 'error'
  169. );
  170. });
  171. });
  172. });
  173. describe('modal actions', () => {
  174. it('calls onClose when backdrop is clicked', async () => {
  175. render(<LinkSpoolModal {...defaultProps} />);
  176. await waitFor(() => {
  177. expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
  178. });
  179. const backdrop = document.querySelector('.bg-black\\/60');
  180. if (backdrop) {
  181. fireEvent.click(backdrop);
  182. expect(defaultProps.onClose).toHaveBeenCalled();
  183. }
  184. });
  185. it('calls onClose when X button is clicked', async () => {
  186. render(<LinkSpoolModal {...defaultProps} />);
  187. await waitFor(() => {
  188. expect(screen.getByRole('heading', { name: /select spool/i })).toBeInTheDocument();
  189. });
  190. const closeButtons = screen.getAllByRole('button');
  191. const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
  192. if (xButton) {
  193. fireEvent.click(xButton);
  194. expect(defaultProps.onClose).toHaveBeenCalled();
  195. }
  196. });
  197. });
  198. });