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>
232 lines
8.7 KiB
TypeScript
232 lines
8.7 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'
|
|
import type { SoloTask } from '@/components/solo/solo-board'
|
|
|
|
// Mock heavy UI primitives to avoid portal/JSDOM issues
|
|
vi.mock('@/components/ui/dialog', () => ({
|
|
Dialog: ({ open, children }: { open: boolean; onOpenChange?: () => void; 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: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>,
|
|
}))
|
|
vi.mock('@/components/ui/textarea', () => ({
|
|
Textarea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
|
|
}))
|
|
vi.mock('@/components/ui/badge', () => ({
|
|
Badge: ({ children, className }: { children: React.ReactNode; className?: string }) =>
|
|
<span className={className}>{children}</span>,
|
|
}))
|
|
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/markdown', () => ({
|
|
Markdown: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
|
}))
|
|
vi.mock('@/components/shared/demo-tooltip', () => ({
|
|
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
}))
|
|
vi.mock('next/link', () => ({
|
|
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
|
}))
|
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
|
vi.mock('@/actions/claude-jobs', () => ({
|
|
enqueueClaudeJobAction: vi.fn(),
|
|
cancelClaudeJobAction: vi.fn(),
|
|
}))
|
|
vi.mock('@/lib/job-status-url', () => ({
|
|
getBranchUrl: (repoUrl: string, branch: string) => `${repoUrl}/tree/${branch}`,
|
|
}))
|
|
|
|
import { useSoloStore } from '@/stores/solo-store'
|
|
import { TaskDetailDialog } from '@/components/solo/task-detail-dialog'
|
|
|
|
const baseTask: SoloTask = {
|
|
id: 'task-1',
|
|
title: 'Test taak',
|
|
description: null,
|
|
implementation_plan: null,
|
|
priority: 2,
|
|
sort_order: 1,
|
|
status: 'TO_DO',
|
|
verify_only: false,
|
|
verify_required: 'ALIGNED_OR_PARTIAL',
|
|
story_id: 'story-1',
|
|
story_code: 'ST-100',
|
|
story_title: 'Test Story',
|
|
task_code: 'ST-100.1',
|
|
pbi_code: null,
|
|
pbi_title: null,
|
|
pbi_description: null,
|
|
}
|
|
|
|
const DEFAULT_PROPS = {
|
|
productId: 'prod-1',
|
|
isDemo: false,
|
|
repoUrl: 'https://github.com/user/repo',
|
|
onClose: vi.fn(),
|
|
}
|
|
|
|
function jobDone(verify_result?: string) {
|
|
return {
|
|
'task-1': {
|
|
job_id: 'j1',
|
|
task_id: 'task-1',
|
|
status: 'done' as const,
|
|
branch: 'feat/job-abc',
|
|
pushed_at: '2026-01-01T00:00:00Z',
|
|
...(verify_result && { verify_result: verify_result as import('@/stores/solo-store').VerifyResultApi }),
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('TaskDetailDialog — verify_result display', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSoloStore.setState({
|
|
tasks: { 'task-1': baseTask },
|
|
claudeJobsByTaskId: {},
|
|
connectedWorkers: 0,
|
|
})
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
})
|
|
|
|
it('shows no verify label when job has no verify_result', () => {
|
|
useSoloStore.setState({ claudeJobsByTaskId: jobDone() })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
expect(screen.queryByText(/Aligned|Gedeeltelijk|Divergent|Geen wijzigingen/)).toBeNull()
|
|
})
|
|
|
|
it('shows Aligned label with text-status-done class for verify_result=aligned', () => {
|
|
useSoloStore.setState({ claudeJobsByTaskId: jobDone('aligned') })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
const label = screen.getByText(/Aligned/)
|
|
expect(label).toHaveClass('text-status-done')
|
|
})
|
|
|
|
it('shows Gedeeltelijk label with text-warning class for verify_result=partial', () => {
|
|
useSoloStore.setState({ claudeJobsByTaskId: jobDone('partial') })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
const label = screen.getByText(/Gedeeltelijk/)
|
|
expect(label).toHaveClass('text-warning')
|
|
})
|
|
|
|
it('shows Divergent label with text-error class for verify_result=divergent', () => {
|
|
useSoloStore.setState({ claudeJobsByTaskId: jobDone('divergent') })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
const label = screen.getByText(/Divergent/)
|
|
expect(label).toHaveClass('text-error')
|
|
})
|
|
|
|
it('shows Geen wijzigingen label with text-muted-foreground class for verify_result=empty', () => {
|
|
useSoloStore.setState({ claudeJobsByTaskId: jobDone('empty') })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
const label = screen.getByText(/Geen wijzigingen/)
|
|
expect(label).toHaveClass('text-muted-foreground')
|
|
})
|
|
})
|
|
|
|
describe('TaskDetailDialog — PR link display', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSoloStore.setState({
|
|
tasks: { 'task-1': baseTask },
|
|
claudeJobsByTaskId: {},
|
|
connectedWorkers: 0,
|
|
})
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
})
|
|
|
|
it('shows "Open PR" link when pr_url is set', () => {
|
|
useSoloStore.setState({
|
|
claudeJobsByTaskId: {
|
|
'task-1': {
|
|
job_id: 'j1',
|
|
task_id: 'task-1',
|
|
status: 'done',
|
|
branch: 'feat/job-abc',
|
|
pushed_at: '2026-01-01T00:00:00Z',
|
|
pr_url: 'https://github.com/org/repo/pull/42',
|
|
},
|
|
},
|
|
})
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
const link = screen.getByRole('link', { name: /Open PR/i })
|
|
expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42')
|
|
})
|
|
|
|
it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => {
|
|
useSoloStore.setState({
|
|
claudeJobsByTaskId: {
|
|
'task-1': {
|
|
job_id: 'j1',
|
|
task_id: 'task-1',
|
|
status: 'done',
|
|
branch: 'feat/job-abc',
|
|
pushed_at: '2026-01-01T00:00:00Z',
|
|
},
|
|
},
|
|
})
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={baseTask} />)
|
|
expect(screen.queryByText(/Open PR/)).toBeNull()
|
|
const link = screen.getByRole('link', { name: /Open op GitHub/i })
|
|
expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc'))
|
|
})
|
|
})
|
|
|
|
describe('TaskDetailDialog — verify_only checkbox', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSoloStore.setState({
|
|
tasks: { 'task-1': baseTask },
|
|
claudeJobsByTaskId: {},
|
|
connectedWorkers: 0,
|
|
})
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
|
|
})
|
|
|
|
it('renders verify_only checkbox unchecked when task.verify_only is false', () => {
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
const checkbox = screen.getByRole('checkbox')
|
|
expect(checkbox).toHaveAttribute('aria-checked', 'false')
|
|
})
|
|
|
|
it('renders verify_only checkbox checked when task.verify_only is true', () => {
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: true }} />)
|
|
const checkbox = screen.getByRole('checkbox')
|
|
expect(checkbox).toHaveAttribute('aria-checked', 'true')
|
|
})
|
|
|
|
it('calls PATCH with verify_only toggled on click', async () => {
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
fireEvent.click(screen.getByRole('checkbox'))
|
|
await waitFor(() => {
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'/api/tasks/task-1',
|
|
expect.objectContaining({
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ verify_only: true }),
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
it('reverts optimistic toggle when PATCH fails', async () => {
|
|
global.fetch = vi.fn().mockResolvedValue({ ok: false })
|
|
render(<TaskDetailDialog {...DEFAULT_PROPS} task={{ ...baseTask, verify_only: false }} />)
|
|
fireEvent.click(screen.getByRole('checkbox'))
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false')
|
|
})
|
|
})
|
|
})
|