- StartSprintButton dialog toont 3-state banner: info met accurate vrije-
stories count + PBI-context, of waarschuwing als geen PBI geselecteerd
is, of waarschuwing als de geselecteerde PBI 0 vrije stories heeft
- Voeg sprint_id toe aan BacklogStory/Story/SprintStory + select in PB-
pagina's en sprint-board mappings, zodat de banner accuraat kan tellen
- createSprintAction: revalidatePath met 'layout' flag voor consistency
met createSprintWithPbisAction (top-nav 'Sprint' link ververst direct)
Sprint-switch data-refresh op alle relevante pagina's:
- BacklogHydrationWrapper: fingerprint-based re-hydratie zodat PB-data
na router.refresh opnieuw uit nieuwe initialData komt (was: useEffect
met lege deps draaide alleen 1x)
- SprintBoardClient: key={sprint.id} forceert remount bij sprint-switch
zodat lokale sprintStories/sprintStoryIds-state vers ge-init wordt
- Solo (desktop + mobile): gebruik resolveActiveSprint(id) ipv eerste
OPEN-sprint, plus key={sprint.id} op SoloBoard voor remount
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
161 lines
6.7 KiB
TypeScript
161 lines
6.7 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { useBacklogStore } from '@/stores/backlog-store'
|
|
import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store'
|
|
|
|
const PBI: BacklogPbi = {
|
|
id: 'pbi-1',
|
|
code: 'PBI-1',
|
|
title: 'Realtime PBI',
|
|
priority: 2,
|
|
description: 'desc',
|
|
created_at: new Date('2024-01-01T00:00:00Z'),
|
|
status: 'ready',
|
|
}
|
|
|
|
const STORY: BacklogStory = {
|
|
id: 'story-1',
|
|
code: 'ST-1',
|
|
title: 'Realtime story',
|
|
description: null,
|
|
acceptance_criteria: null,
|
|
priority: 2,
|
|
status: 'OPEN',
|
|
pbi_id: 'pbi-1',
|
|
sprint_id: null,
|
|
created_at: new Date('2024-01-01T00:00:00Z'),
|
|
}
|
|
|
|
const TASK: BacklogTask = {
|
|
id: 'task-1',
|
|
title: 'Realtime task',
|
|
description: null,
|
|
priority: 2,
|
|
status: 'TO_DO',
|
|
sort_order: 1,
|
|
story_id: 'story-1',
|
|
created_at: new Date('2024-01-01T00:00:00Z'),
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PBI
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('PBI payload contract', () => {
|
|
it('INSERT: entity appears in pbis with correct title and status', () => {
|
|
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
|
|
const state = useBacklogStore.getState()
|
|
expect(state.pbis).toHaveLength(1)
|
|
expect(state.pbis[0].id).toBe('pbi-1')
|
|
expect(state.pbis[0].title).toBe('Realtime PBI')
|
|
expect(state.pbis[0].status).toBe('ready')
|
|
})
|
|
|
|
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
|
|
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
|
|
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
|
|
expect(useBacklogStore.getState().pbis).toHaveLength(1)
|
|
})
|
|
|
|
it('UPDATE: changed_fields partial merges into existing entity', () => {
|
|
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
|
|
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const })
|
|
const pbi = useBacklogStore.getState().pbis[0]
|
|
expect(pbi.title).toBe('Updated PBI')
|
|
expect(pbi.status).toBe('in_sprint')
|
|
expect(pbi.priority).toBe(2) // unchanged field retained
|
|
})
|
|
|
|
it('DELETE: entity is removed from pbis', () => {
|
|
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
|
|
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
|
|
expect(useBacklogStore.getState().pbis).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Story
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Story payload contract', () => {
|
|
it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
|
|
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
|
|
const bucket = useBacklogStore.getState().storiesByPbi['pbi-1']
|
|
expect(bucket).toHaveLength(1)
|
|
expect(bucket[0].id).toBe('story-1')
|
|
expect(bucket[0].title).toBe('Realtime story')
|
|
expect(bucket[0].status).toBe('OPEN')
|
|
})
|
|
|
|
it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => {
|
|
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
|
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
|
|
})
|
|
|
|
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
|
|
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
|
|
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
|
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
|
|
})
|
|
|
|
it('UPDATE: changed_fields partial merges into existing story', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
|
|
useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' })
|
|
const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0]
|
|
expect(story.title).toBe('Updated story')
|
|
expect(story.status).toBe('IN_SPRINT')
|
|
expect(story.priority).toBe(2) // unchanged field retained
|
|
})
|
|
|
|
it('DELETE: entity is removed from its pbi bucket', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
|
|
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
|
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Task payload contract', () => {
|
|
it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } })
|
|
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
|
|
const bucket = useBacklogStore.getState().tasksByStory['story-1']
|
|
expect(bucket).toHaveLength(1)
|
|
expect(bucket[0].id).toBe('task-1')
|
|
expect(bucket[0].title).toBe('Realtime task')
|
|
expect(bucket[0].status).toBe('TO_DO')
|
|
})
|
|
|
|
it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => {
|
|
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
|
|
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
|
|
})
|
|
|
|
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
|
|
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
|
|
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
|
|
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
|
|
})
|
|
|
|
it('UPDATE: changed_fields partial merges into existing task', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
|
|
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' })
|
|
const task = useBacklogStore.getState().tasksByStory['story-1'][0]
|
|
expect(task.title).toBe('Updated task')
|
|
expect(task.status).toBe('IN_PROGRESS')
|
|
expect(task.sort_order).toBe(1) // unchanged field retained
|
|
})
|
|
|
|
it('DELETE: entity is removed from its story bucket', () => {
|
|
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
|
|
useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' })
|
|
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0)
|
|
})
|
|
})
|