|
@@ -0,0 +1,290 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 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<typeof import('../../contexts/ToastContext')>();
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('displays tray info', async () => {
|
|
|
|
|
+ render(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(screen.getByText('PLA Basic')).toBeInTheDocument();
|
|
|
|
|
+ expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('displays tray UUID', async () => {
|
|
|
|
|
+ render(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(document.querySelector('.animate-spin')).toBeInTheDocument();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('displays unlinked spools list', async () => {
|
|
|
|
|
+ render(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ await waitFor(() => {
|
|
|
|
|
+ expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ it('does not render when isOpen is false', () => {
|
|
|
|
|
+ render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
|
|
|
|
|
+ expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ describe('spool selection', () => {
|
|
|
|
|
+ it('allows selecting a spool', async () => {
|
|
|
|
|
+ render(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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(<LinkSpoolModal {...defaultProps} />);
|
|
|
|
|
+
|
|
|
|
|
+ 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();
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|