/** * Tests for the LinkSpoolModal component. * * Tests the Spoolman link spool modal including: * - Displaying unlinked spools * - Selecting a spool to link * - Link success with toast notification * - Link error with toast notification */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { screen, waitFor, fireEvent } from '@testing-library/react'; import { render } from '../utils'; import { LinkSpoolModal } from '../../components/LinkSpoolModal'; // Mock the API client vi.mock('../../api/client', () => ({ api: { getUnlinkedSpools: vi.fn(), linkSpool: vi.fn(), getSettings: vi.fn().mockResolvedValue({}), getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }), }, })); // Mock the toast context const mockShowToast = vi.fn(); vi.mock('../../contexts/ToastContext', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useToast: () => ({ showToast: mockShowToast }), }; }); // Import mocked module import { api } from '../../api/client'; describe('LinkSpoolModal', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4', trayInfo: { type: 'PLA Basic', color: 'FF0000', location: 'AMS A1', }, }; const mockUnlinkedSpools = [ { id: 1, filament_name: 'PLA Red', filament_material: 'PLA', filament_color_hex: 'FF0000', remaining_weight: 800, location: 'Shelf A', }, { id: 2, filament_name: 'PETG Blue', filament_material: 'PETG', filament_color_hex: '0000FF', remaining_weight: 500, location: null, }, ]; beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools); vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' }); }); describe('rendering', () => { it('renders modal title', async () => { render(); await waitFor(() => { expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); }); }); it('displays tray info', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Basic')).toBeInTheDocument(); expect(screen.getByText('(AMS A1)')).toBeInTheDocument(); }); }); it('displays tray UUID', async () => { render(); await waitFor(() => { expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument(); }); }); it('shows loading state while fetching spools', async () => { // Delay the response vi.mocked(api.getUnlinkedSpools).mockImplementation( () => new Promise(() => {}) ); render(); await waitFor(() => { expect(document.querySelector('.animate-spin')).toBeInTheDocument(); }); }); it('displays unlinked spools list', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); expect(screen.getByText('PETG Blue')).toBeInTheDocument(); }); }); it('shows message when no unlinked spools', async () => { vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]); render(); await waitFor(() => { expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument(); }); }); it('does not render when isOpen is false', () => { render(); expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument(); }); }); describe('spool selection', () => { it('allows selecting a spool', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); // Click to select spool fireEvent.click(screen.getByText('PLA Red')); // Should show check mark (via visual styling) const selectedButton = screen.getByText('PLA Red').closest('button'); expect(selectedButton).toHaveClass('border-bambu-green'); }); it('link button is disabled until spool is selected', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); const linkButton = screen.getByRole('button', { name: /link spool/i }); expect(linkButton).toBeDisabled(); // Select a spool fireEvent.click(screen.getByText('PLA Red')); expect(linkButton).not.toBeDisabled(); }); }); describe('linking', () => { it('calls linkSpool API on submit', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); // Select a spool fireEvent.click(screen.getByText('PLA Red')); // Click link button fireEvent.click(screen.getByRole('button', { name: /link spool/i })); await waitFor(() => { expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid); }); }); it('shows success toast on successful link', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('PLA Red')); fireEvent.click(screen.getByRole('button', { name: /link spool/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( 'Spool linked to Spoolman successfully', 'success' ); }); }); it('calls onClose after successful link', async () => { render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('PLA Red')); fireEvent.click(screen.getByRole('button', { name: /link spool/i })); await waitFor(() => { expect(defaultProps.onClose).toHaveBeenCalled(); }); }); it('shows error toast on link failure', async () => { const errorMessage = 'Failed to update spool'; vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage)); render(); await waitFor(() => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('PLA Red')); fireEvent.click(screen.getByRole('button', { name: /link spool/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( `Failed to link spool: ${errorMessage}`, 'error' ); }); }); }); describe('modal actions', () => { it('calls onClose when cancel button is clicked', async () => { render(); await waitFor(() => { expect(screen.getByText('Cancel')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Cancel')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('calls onClose when backdrop is clicked', async () => { render(); await waitFor(() => { expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); }); // Click the backdrop (the element with bg-black/60) const backdrop = document.querySelector('.bg-black\\/60'); if (backdrop) { fireEvent.click(backdrop); expect(defaultProps.onClose).toHaveBeenCalled(); } }); it('calls onClose when X button is clicked', async () => { render(); await waitFor(() => { expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); }); // Find and click the X button in the header const closeButtons = screen.getAllByRole('button'); const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x')); if (xButton) { fireEvent.click(xButton); expect(defaultProps.onClose).toHaveBeenCalled(); } }); }); });