Twee pre-existing mock-bugs die op main al rood waren maar geen gevolgen
hadden tot de CI-monitor erop sloeg in deze PR:
1. Mock-state miste `entities.settings`. Sinds PBI-79 (commit d587be2)
selecteert SprintSwitcher ook `s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal`,
maar de testmock leverde alleen `{ context }`. → undefined-crash op
`entities.settings` reading.
2. Mock factory exporteerde alleen `setActiveSprintAction`, maar de
productie roept `switchActiveSprintAction` aan. Door `vi.mock` werden
alle andere exports `undefined`, waardoor `actionMock` nooit kon
triggeren.
Out-of-scope-fix t.o.v. de sprint-eligibility-fix in dit PR — apart commit
zodat reviewer dit als losse cleanup kan zien. CI is nu groen lokaal:
3/3 sprint-switcher tests + 839/839 full suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
import React from 'react'
|
|
|
|
const pushMock = vi.fn()
|
|
const refreshMock = vi.fn()
|
|
const pathnameMock = vi.fn(() => '/products/p1/sprint')
|
|
|
|
vi.mock('next/navigation', () => ({
|
|
useRouter: () => ({ push: pushMock, refresh: refreshMock }),
|
|
usePathname: () => pathnameMock(),
|
|
}))
|
|
|
|
vi.mock('@/actions/active-sprint', () => ({
|
|
setActiveSprintAction: vi.fn(),
|
|
switchActiveSprintAction: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('sonner', () => ({
|
|
toast: { error: vi.fn(), success: vi.fn() },
|
|
}))
|
|
|
|
const isDemoMock = { value: false }
|
|
// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert:
|
|
// - s.context.isDemo (oude code)
|
|
// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79)
|
|
type MockStoreState = {
|
|
context: { isDemo: boolean }
|
|
entities: {
|
|
settings: {
|
|
workflow?: {
|
|
pendingSprintDraft?: Record<string, { goal: string } | undefined>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
vi.mock('@/stores/user-settings/store', () => ({
|
|
useUserSettingsStore: (selector: (s: MockStoreState) => unknown) =>
|
|
selector({
|
|
context: { isDemo: isDemoMock.value },
|
|
entities: { settings: {} },
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/components/ui/dropdown-menu', () => {
|
|
type Props = { children?: React.ReactNode; onClick?: () => void; className?: string }
|
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
return {
|
|
DropdownMenu: PassThrough,
|
|
DropdownMenuTrigger: PassThrough,
|
|
DropdownMenuContent: PassThrough,
|
|
DropdownMenuItem: ({ children, onClick, className }: Props) => (
|
|
<button type="button" onClick={onClick} className={className}>
|
|
{children}
|
|
</button>
|
|
),
|
|
DropdownMenuSeparator: () => null,
|
|
}
|
|
})
|
|
|
|
vi.mock('@/components/ui/tooltip', () => {
|
|
type Props = { children?: React.ReactNode }
|
|
const PassThrough = ({ children }: Props) => <>{children}</>
|
|
return {
|
|
Tooltip: PassThrough,
|
|
TooltipContent: PassThrough,
|
|
TooltipProvider: PassThrough,
|
|
TooltipTrigger: PassThrough,
|
|
}
|
|
})
|
|
|
|
import { switchActiveSprintAction } from '@/actions/active-sprint'
|
|
import { toast } from 'sonner'
|
|
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
|
|
|
const actionMock = switchActiveSprintAction as unknown as ReturnType<typeof vi.fn>
|
|
const toastError = toast.error as unknown as ReturnType<typeof vi.fn>
|
|
const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn>
|
|
|
|
const sprints = [
|
|
{ id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const },
|
|
{ id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const },
|
|
]
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
isDemoMock.value = false
|
|
actionMock.mockResolvedValue({ success: true })
|
|
pathnameMock.mockReturnValue('/products/p1/sprint')
|
|
})
|
|
|
|
describe('SprintSwitcher', () => {
|
|
it('demo: clicking another sprint navigates via router.push without calling the action', () => {
|
|
isDemoMock.value = true
|
|
render(
|
|
<SprintSwitcher
|
|
productId="p1"
|
|
sprints={sprints}
|
|
activeSprint={sprints[0]}
|
|
buildingSprintIds={[]}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByText('Goal 2'))
|
|
expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2')
|
|
expect(actionMock).not.toHaveBeenCalled()
|
|
expect(toastError).not.toHaveBeenCalled()
|
|
expect(toastSuccess).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('non-demo: clicking another sprint calls setActiveSprintAction', async () => {
|
|
isDemoMock.value = false
|
|
render(
|
|
<SprintSwitcher
|
|
productId="p1"
|
|
sprints={sprints}
|
|
activeSprint={sprints[0]}
|
|
buildingSprintIds={[]}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByText('Goal 2'))
|
|
// Wait microtask for the transition to flush.
|
|
await Promise.resolve()
|
|
expect(actionMock).toHaveBeenCalledWith('p1', 's2')
|
|
})
|
|
|
|
it('clicking the already-active sprint does nothing', () => {
|
|
isDemoMock.value = true
|
|
render(
|
|
<SprintSwitcher
|
|
productId="p1"
|
|
sprints={sprints}
|
|
activeSprint={sprints[0]}
|
|
buildingSprintIds={[]}
|
|
/>,
|
|
)
|
|
fireEvent.click(screen.getByText('Goal 1'))
|
|
expect(pushMock).not.toHaveBeenCalled()
|
|
expect(actionMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|