diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx index ecbc6af..29c29c0 100644 --- a/__tests__/components/shared/sprint-switcher.test.tsx +++ b/__tests__/components/shared/sprint-switcher.test.tsx @@ -24,6 +24,19 @@ vi.mock('sonner', () => ({ })) 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 + } + } + } +} vi.mock('@/stores/user-settings/store', () => ({ useUserSettingsStore: (selector: (s: { context: { isDemo: boolean }; entities: { settings: { workflow: null } } }) => unknown) => selector({ context: { isDemo: isDemoMock.value }, entities: { settings: { workflow: null } } }), diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts index 9eb3a5d..bf6edbe 100644 --- a/__tests__/lib/sprint-conflicts.test.ts +++ b/__tests__/lib/sprint-conflicts.test.ts @@ -37,20 +37,63 @@ describe('isEligibleForSprint', () => { ).toBe(false) }) - it('returns false when story is in any sprint (open status)', () => { + it('returns false when story is in an OPEN sprint', () => { expect( isEligibleForSprint({ sprint_id: 'abc', - status: 'OPEN' as StoryStatus, + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'OPEN' }, }), ).toBe(false) }) - it('returns false when story is in any sprint (done status)', () => { + it('returns false when story is DONE (sprint_id irrelevant)', () => { expect( isEligibleForSprint({ sprint_id: 'abc', status: 'DONE' as StoryStatus, + sprint: { status: 'CLOSED' }, + }), + ).toBe(false) + }) + + it('returns true when story is in a CLOSED sprint (released back to planning)', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'CLOSED' }, + }), + ).toBe(true) + }) + + it('returns true when story is in an ARCHIVED sprint', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'ARCHIVED' }, + }), + ).toBe(true) + }) + + it('returns true when story is in a FAILED sprint', () => { + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, + sprint: { status: 'FAILED' }, + }), + ).toBe(true) + }) + + it('returns false when sprint_id is set but sprint relation is missing (defensive)', () => { + // Zonder sprint-data weten we niet of die OPEN is, dus blijven we + // conservatief — niet eligible. + expect( + isEligibleForSprint({ + sprint_id: 'abc', + status: 'IN_SPRINT' as StoryStatus, }), ).toBe(false) }) @@ -115,7 +158,45 @@ describe('partitionByEligibility', () => { expect(result.eligible).toEqual(['s1']) }) - it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => { + it('frees stories from a CLOSED sprint — they become eligible again', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-closed', + status: 'IN_SPRINT', + sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1']) + expect(result.eligible).toEqual(['s1']) + expect(result.crossSprint).toEqual([]) + expect(result.notEligible).toEqual([]) + }) + + it('frees stories from ARCHIVED and FAILED sprints', async () => { + const prisma = mockPrisma([ + { + id: 's1', + sprint_id: 'sprint-arch', + status: 'IN_SPRINT', + sprint: { id: 'sprint-arch', code: 'SP-A', status: 'ARCHIVED' }, + }, + { + id: 's2', + sprint_id: 'sprint-fail', + status: 'IN_SPRINT', + sprint: { id: 'sprint-fail', code: 'SP-F', status: 'FAILED' }, + }, + ]) + const result = await partitionByEligibility(prisma, ['s1', 's2']) + expect(result.eligible).toEqual(['s1', 's2']) + expect(result.notEligible).toEqual([]) + }) + + it('a DONE story in a CLOSED sprint is notEligible because DONE (sprint inactive)', async () => { + // Volgorde: niet-actieve sprint blokkeert niet meer, dus de DONE-check + // bepaalt de reason. Vroeger werd dit 'IN_OTHER_SPRINT' — dat was misleidend + // omdat de sprint helemaal niet meer actief was. const prisma = mockPrisma([ { id: 's1', @@ -126,9 +207,8 @@ describe('partitionByEligibility', () => { ]) const result = await partitionByEligibility(prisma, ['s1']) expect(result.crossSprint).toEqual([]) - expect(result.notEligible).toEqual([ - { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, - ]) + expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) + expect(result.eligible).toEqual([]) }) it('respects excludeSprintId — story in same sprint is eligible', async () => { diff --git a/lib/sprint-conflicts.ts b/lib/sprint-conflicts.ts index 780c4da..c255ca5 100644 --- a/lib/sprint-conflicts.ts +++ b/lib/sprint-conflicts.ts @@ -1,4 +1,9 @@ -import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client' +import type { + Prisma, + PrismaClient, + SprintStatus, + StoryStatus, +} from '@prisma/client' export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT' @@ -17,10 +22,20 @@ export type EligibilityPartition = { type StoryEligibilityInput = { sprint_id: string | null status: StoryStatus + // De bijbehorende sprint, indien sprint_id !== null. Alleen sprints met + // status='OPEN' blokkeren eligibility — een story uit een CLOSED/ARCHIVED/ + // FAILED sprint is weer beschikbaar voor planning (consistent met + // getBlockingSprintMap, dat alleen OPEN sprints als blocking telt). + sprint?: { status: SprintStatus } | null } export function isEligibleForSprint(story: StoryEligibilityInput): boolean { - return story.sprint_id === null && story.status !== 'DONE' + if (story.status === 'DONE') return false + if (story.sprint_id === null) return true + // Story heeft een sprint_id — sprint-status MOET bekend zijn om eligibility + // te bepalen. Ontbrekende sprint-data is conservatief: niet eligible. + if (!story.sprint) return false + return story.sprint.status !== 'OPEN' } type PrismaLike = Pick | Prisma.TransactionClient @@ -49,17 +64,20 @@ export async function partitionByEligibility( const crossSprint: CrossSprintBlock[] = [] for (const story of stories) { - const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId - const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId + // Een story blokkeert alleen als hij in een ANDERE sprint zit DIE NOG OPEN + // is. Stories die in een CLOSED/ARCHIVED/FAILED sprint zaten, zijn weer + // vrij voor planning — consistent met getBlockingSprintMap. + const inOtherActiveSprint = + story.sprint_id !== null && + story.sprint_id !== excludeSprintId && + story.sprint?.status === 'OPEN' - if (inOtherSprint) { - if (story.sprint && story.sprint.status === 'OPEN') { - crossSprint.push({ - storyId: story.id, - sprintId: story.sprint.id, - sprintName: story.sprint.code, - }) - } + if (inOtherActiveSprint && story.sprint) { + crossSprint.push({ + storyId: story.id, + sprintId: story.sprint.id, + sprintName: story.sprint.code, + }) notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' }) continue } @@ -69,11 +87,6 @@ export async function partitionByEligibility( continue } - if (inSameSprint) { - eligible.push(story.id) - continue - } - eligible.push(story.id) }