LinkSpoolModal.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. /**
  2. * Tests for the LinkSpoolModal component.
  3. *
  4. * Tests the Spoolman link spool modal including:
  5. * - Displaying unlinked spools
  6. * - Selecting a spool to link
  7. * - Link success with toast notification
  8. * - Link error with toast notification
  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({ enabled: false, configured: 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. trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
  39. trayInfo: {
  40. type: 'PLA Basic',
  41. color: 'FF0000',
  42. location: 'AMS A1',
  43. },
  44. };
  45. const mockUnlinkedSpools = [
  46. {
  47. id: 1,
  48. filament_name: 'PLA Red',
  49. filament_material: 'PLA',
  50. filament_color_hex: 'FF0000',
  51. remaining_weight: 800,
  52. location: 'Shelf A',
  53. },
  54. {
  55. id: 2,
  56. filament_name: 'PETG Blue',
  57. filament_material: 'PETG',
  58. filament_color_hex: '0000FF',
  59. remaining_weight: 500,
  60. location: null,
  61. },
  62. ];
  63. beforeEach(() => {
  64. vi.clearAllMocks();
  65. vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
  66. vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
  67. });
  68. describe('rendering', () => {
  69. it('renders modal title', async () => {
  70. render(<LinkSpoolModal {...defaultProps} />);
  71. await waitFor(() => {
  72. expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
  73. });
  74. });
  75. it('displays tray info', async () => {
  76. render(<LinkSpoolModal {...defaultProps} />);
  77. await waitFor(() => {
  78. expect(screen.getByText('PLA Basic')).toBeInTheDocument();
  79. expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
  80. });
  81. });
  82. it('displays tray UUID', async () => {
  83. render(<LinkSpoolModal {...defaultProps} />);
  84. await waitFor(() => {
  85. expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
  86. });
  87. });
  88. it('shows loading state while fetching spools', async () => {
  89. // Delay the response
  90. vi.mocked(api.getUnlinkedSpools).mockImplementation(
  91. () => new Promise(() => {})
  92. );
  93. render(<LinkSpoolModal {...defaultProps} />);
  94. await waitFor(() => {
  95. expect(document.querySelector('.animate-spin')).toBeInTheDocument();
  96. });
  97. });
  98. it('displays unlinked spools list', async () => {
  99. render(<LinkSpoolModal {...defaultProps} />);
  100. await waitFor(() => {
  101. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  102. expect(screen.getByText('PETG Blue')).toBeInTheDocument();
  103. });
  104. });
  105. it('shows message when no unlinked spools', async () => {
  106. vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
  107. render(<LinkSpoolModal {...defaultProps} />);
  108. await waitFor(() => {
  109. expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument();
  110. });
  111. });
  112. it('does not render when isOpen is false', () => {
  113. render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
  114. expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument();
  115. });
  116. });
  117. describe('spool selection', () => {
  118. it('allows selecting a spool', async () => {
  119. render(<LinkSpoolModal {...defaultProps} />);
  120. await waitFor(() => {
  121. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  122. });
  123. // Click to select spool
  124. fireEvent.click(screen.getByText('PLA Red'));
  125. // Should show check mark (via visual styling)
  126. const selectedButton = screen.getByText('PLA Red').closest('button');
  127. expect(selectedButton).toHaveClass('border-bambu-green');
  128. });
  129. it('link button is disabled until spool is selected', async () => {
  130. render(<LinkSpoolModal {...defaultProps} />);
  131. await waitFor(() => {
  132. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  133. });
  134. const linkButton = screen.getByRole('button', { name: /link spool/i });
  135. expect(linkButton).toBeDisabled();
  136. // Select a spool
  137. fireEvent.click(screen.getByText('PLA Red'));
  138. expect(linkButton).not.toBeDisabled();
  139. });
  140. });
  141. describe('linking', () => {
  142. it('calls linkSpool API on submit', async () => {
  143. render(<LinkSpoolModal {...defaultProps} />);
  144. await waitFor(() => {
  145. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  146. });
  147. // Select a spool
  148. fireEvent.click(screen.getByText('PLA Red'));
  149. // Click link button
  150. fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
  151. await waitFor(() => {
  152. expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
  153. });
  154. });
  155. it('shows success toast on successful link', async () => {
  156. render(<LinkSpoolModal {...defaultProps} />);
  157. await waitFor(() => {
  158. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  159. });
  160. fireEvent.click(screen.getByText('PLA Red'));
  161. fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
  162. await waitFor(() => {
  163. expect(mockShowToast).toHaveBeenCalledWith(
  164. 'Spool linked to Spoolman successfully',
  165. 'success'
  166. );
  167. });
  168. });
  169. it('calls onClose after successful link', async () => {
  170. render(<LinkSpoolModal {...defaultProps} />);
  171. await waitFor(() => {
  172. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  173. });
  174. fireEvent.click(screen.getByText('PLA Red'));
  175. fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
  176. await waitFor(() => {
  177. expect(defaultProps.onClose).toHaveBeenCalled();
  178. });
  179. });
  180. it('shows error toast on link failure', async () => {
  181. const errorMessage = 'Failed to update spool';
  182. vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
  183. render(<LinkSpoolModal {...defaultProps} />);
  184. await waitFor(() => {
  185. expect(screen.getByText('PLA Red')).toBeInTheDocument();
  186. });
  187. fireEvent.click(screen.getByText('PLA Red'));
  188. fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
  189. await waitFor(() => {
  190. expect(mockShowToast).toHaveBeenCalledWith(
  191. `Failed to link spool: ${errorMessage}`,
  192. 'error'
  193. );
  194. });
  195. });
  196. });
  197. describe('modal actions', () => {
  198. it('calls onClose when cancel button is clicked', async () => {
  199. render(<LinkSpoolModal {...defaultProps} />);
  200. await waitFor(() => {
  201. expect(screen.getByText('Cancel')).toBeInTheDocument();
  202. });
  203. fireEvent.click(screen.getByText('Cancel'));
  204. expect(defaultProps.onClose).toHaveBeenCalled();
  205. });
  206. it('calls onClose when backdrop is clicked', async () => {
  207. render(<LinkSpoolModal {...defaultProps} />);
  208. await waitFor(() => {
  209. expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
  210. });
  211. // Click the backdrop (the element with bg-black/60)
  212. const backdrop = document.querySelector('.bg-black\\/60');
  213. if (backdrop) {
  214. fireEvent.click(backdrop);
  215. expect(defaultProps.onClose).toHaveBeenCalled();
  216. }
  217. });
  218. it('calls onClose when X button is clicked', async () => {
  219. render(<LinkSpoolModal {...defaultProps} />);
  220. await waitFor(() => {
  221. expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
  222. });
  223. // Find and click the X button in the header
  224. const closeButtons = screen.getAllByRole('button');
  225. const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
  226. if (xButton) {
  227. fireEvent.click(xButton);
  228. expect(defaultProps.onClose).toHaveBeenCalled();
  229. }
  230. });
  231. });
  232. });