Scrum4Me/__tests__/components/shared/sprint-switcher.test.tsx
Janpeter Visser 3d5c22382c
feat(PBI-91): expliciete schermstaat + draft-zichtbaarheid PB-page (#210)
* docs(ST-1369): plan PBI-91 — expliciete schermstaat + draft-zichtbaarheid

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1369): screen-state module — ScreenState + deriveScreenState()

Pure afleidingslaag die de verspreide schermstaat-derivatie van de Product
Backlog page consolideert tot één testbaar ScreenState-model. Nog geen
consumers — die volgen in T-1035/T-1036.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ST-1369): unit-tests voor deriveScreenState()

Dekt alle vier de kinds (NO_SPRINT, DRAFT, ACTIVE, EDITING), de building-flag
en de draft-voorrang boven een actieve sprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1369): SprintSwitcher op deriveScreenState + draft op trigger (G5)

De trigger-knop toont nu de concept-sprint zodra er een sprint-draft loopt,
niet langer alleen de (disabled) dropdown-regel. Schermstaat-afleiding loopt
via de pure deriveScreenState() i.p.v. losse flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1369): NewSprintTrigger achter isActiveProduct-gate (G6)

De "Nieuwe sprint"-knop rendert niet langer op een niet-actief product —
een sprint-draft starten daar was verwarrend. page.tsx geeft de bestaande
isActiveProduct-flag door.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ST-1369): component-tests voor draft-op-trigger (G5) en isActiveProduct-gate (G6)

sprint-switcher: trigger toont concept-sprint bij een pending draft, en geen
concept-label zonder draft. new-sprint-trigger: nieuw testbestand — rendert
niet op een niet-actief product, wel op een actief product zonder draft.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:45:35 +02:00

174 lines
5.2 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(),
clearActiveSprintAction: vi.fn(),
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}))
const isDemoMock = { value: false }
const workflowMock: {
value:
| { pendingSprintDraft?: Record<string, { goal: string } | undefined> }
| undefined
} = { value: undefined }
// 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: { workflow: workflowMock.value } },
}),
}))
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
workflowMock.value = undefined
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()
})
it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => {
workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } }
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={null}
buildingSprintIds={[]}
/>,
)
expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument()
})
it('shows no concept label on the trigger when no draft is pending', () => {
render(
<SprintSwitcher
productId="p1"
sprints={sprints}
activeSprint={sprints[0]}
buildingSprintIds={[]}
/>,
)
expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument()
})
})