// @vitest-environment jsdom import '@testing-library/jest-dom' import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({ mockPreviewEnqueueAllAction: vi.fn(), mockEnqueueClaudeJobsBatchAction: vi.fn(), })) vi.mock('@/actions/claude-jobs', () => ({ previewEnqueueAllAction: mockPreviewEnqueueAllAction, enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction, cancelClaudeJobAction: vi.fn(), enqueueClaudeJobAction: vi.fn(), })) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, DragOverlay: () => null, PointerSensor: class {}, useSensor: vi.fn(() => ({})), useSensors: vi.fn(() => []), closestCorners: vi.fn(), })) vi.mock('@/components/ui/button', () => ({ Button: ({ children, onClick, disabled, }: { children?: React.ReactNode onClick?: () => void disabled?: boolean }) => ( ), })) vi.mock('@/components/ui/dialog', () => ({ Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => open ?
{children}
: null, DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, })) vi.mock('@/components/ui/tooltip', () => ({ TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => r ? <>{r} : <>{children}, TooltipContent: () => null, })) vi.mock('@/components/shared/demo-tooltip', () => ({ DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, })) vi.mock('@/components/split-pane/split-pane', () => ({ SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}, })) vi.mock('@/components/solo/solo-column', () => ({ SoloColumn: () =>
, })) vi.mock('@/components/solo/solo-task-card', () => ({ SoloTaskCardOverlay: () => null, })) vi.mock('@/components/solo/task-detail-dialog', () => ({ TaskDetailDialog: () => null, })) vi.mock('@/components/solo/unassigned-stories-sheet', () => ({ UnassignedStoriesSheet: () => null, })) vi.mock('@/lib/task-status', () => ({ taskStatusToApi: (s: string) => s.toLowerCase(), })) import { useSoloStore } from '@/stores/solo-store' import { SoloBoard } from '@/components/solo/solo-board' import { toast } from 'sonner' const PRODUCT_ID = 'prod-1' const TODO_TASK = { id: 't1', title: 'Task 1', description: null, implementation_plan: null, priority: 1, sort_order: 1, status: 'TO_DO' as const, verify_only: false, verify_required: 'ALIGNED_OR_PARTIAL' as const, story_id: 'story-1', story_code: 'ST-1', story_title: 'Story 1', task_code: 'ST-1.1', } const DEFAULT_PROPS = { productId: PRODUCT_ID, sprintGoal: 'Sprint goal', tasks: [TODO_TASK], unassignedStories: [], isDemo: false, currentUserId: 'user-1', } const PREVIEW_NO_BLOCKER = { tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }], blockerIndex: null, blockerReason: null, } const PREVIEW_WITH_BLOCKER = { tasks: [ { id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, { id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, { id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, ], blockerIndex: 2, blockerReason: 'task-review' as const, } beforeEach(() => { vi.clearAllMocks() useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 }) }) describe('SoloBoard — batch-enqueue flow', () => { it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => { mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER) mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 }) render() fireEvent.click(screen.getByText(/Start agents/)) await waitFor(() => { expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID) expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1']) expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent')) }) }) it('blocker: shows dialog when preview returns blockerIndex', async () => { mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) render() fireEvent.click(screen.getByText(/Start agents/)) await waitFor(() => { expect(screen.getByTestId('dialog')).toBeInTheDocument() expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument() }) expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() }) it('blocker dialog confirm: enqueues prefix tasks and closes', async () => { mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 }) render() fireEvent.click(screen.getByText(/Start agents/)) await waitFor(() => screen.getByTestId('dialog')) fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/)) await waitFor(() => { expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2']) expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents')) expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() }) }) it('blocker dialog cancel: closes dialog without enqueuing', async () => { mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) render() fireEvent.click(screen.getByText(/Start agents/)) await waitFor(() => screen.getByTestId('dialog')) fireEvent.click(screen.getByText('Annuleer')) await waitFor(() => { expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() }) expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() }) it('preview error: shows toast without opening dialog', async () => { mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' }) render() fireEvent.click(screen.getByText(/Start agents/)) await waitFor(() => { expect(toast.error).toHaveBeenCalledWith('Geen toegang') }) expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() }) })