pbi_code, pbi_title, pbi_description (nullable) toegevoegd aan SoloTask-interface. Desktop en mobile solo-page: story.pbi select + mapping via ?. en ?? null. Test-fixtures bijgewerkt (3 bestanden). 72 testfiles groen, tsc + build slagen. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
7.2 KiB
TypeScript
207 lines
7.2 KiB
TypeScript
// @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
|
|
}) => (
|
|
<button onClick={onClick} disabled={disabled}>
|
|
{children}
|
|
</button>
|
|
),
|
|
}))
|
|
vi.mock('@/components/ui/dialog', () => ({
|
|
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
|
open ? <div data-testid="dialog">{children}</div> : null,
|
|
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
|
}))
|
|
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: () => <div data-testid="solo-column" />,
|
|
}))
|
|
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',
|
|
pbi_code: null,
|
|
pbi_title: null,
|
|
pbi_description: null,
|
|
}
|
|
|
|
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(<SoloBoard {...DEFAULT_PROPS} />)
|
|
|
|
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(<SoloBoard {...DEFAULT_PROPS} />)
|
|
|
|
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(<SoloBoard {...DEFAULT_PROPS} />)
|
|
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(<SoloBoard {...DEFAULT_PROPS} />)
|
|
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(<SoloBoard {...DEFAULT_PROPS} />)
|
|
fireEvent.click(screen.getByText(/Start agents/))
|
|
|
|
await waitFor(() => {
|
|
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
|
|
})
|
|
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
|
})
|
|
})
|