* feat(code): add parseCodeNumber helper to lib/code.ts
Pure helper that extracts the trailing numeric sequence from a code string
(ST-007 → 7, T-42 → 42). Non-conforming codes fall back to Number.MAX_SAFE_INTEGER
so they sort to the end. Includes 5 unit tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(tasks): add code field to BacklogTask type and all task selects
Adds `code: string | null` to BacklogTask interface and includes it in
all Prisma task.findMany selects (backlog API, stories tasks API, page
hydration routes). Updates coerceTaskPayload and test fixtures to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(sort-order): derive story/task sort_order from parseCodeNumber(code)
All create paths (createStoryAction, saveTask, createTaskAction,
materializeIdeaPlanAction) and code-edit paths (updateStoryAction, saveTask
update) now set sort_order = parseCodeNumber(code) instead of last+1.
Removes stale last-record queries from create paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(sort-order): decouple sprint membership actions from sort_order
createSprintAction and addStoryToSprintAction no longer write sort_order
when adding stories to a sprint. sort_order is derived from code via
parseCodeNumber, so membership should only set sprint_id + status.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(ordering): remove priority from all story/task orderBy
Story- en taak-ordering is nu puur sort_order asc (created_at als
tiebreaker). PBI-ordering (priority + sort_order) blijft ongewijzigd.
Gewijzigd: backlog/route, pbis/stories/route, claude-context/route,
next-story/route, workspace/route, tasks/route, sprint-runs (query +
in-memory sort), solo-workspace-server, page.tsx (app + mobile + sprint),
store compareStory, actions/sprints story-query, next-story test.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(dnd): remove drag-and-drop reorder for stories and tasks
- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests
* feat(backlog): toon code-badge op backlog-taakkaarten
Geeft code={task.code} door aan <BacklogCard> in TaskCard (task-panel.tsx).
BacklogCard rendert de CodeBadge al conditionally — alleen de prop ontbrak.
* feat(migration): backfill story/task sort_order from code numeric suffix
One-time Prisma migration that sets sort_order = trailing numeric part
of code for all existing stories and tasks, consistent with
parseCodeNumber (fallback = Number.MAX_SAFE_INTEGER for non-conforming
codes). PBIs are intentionally excluded.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs+tests(sort-order): update for code-binding order on stories/tasks
- Rewrite docs/patterns/sort-order.md: float-insertion PBI only; story/task
sort_order = parseCodeNumber(code), never drag/membership mutated
- Update plan-to-pbi-flow.md: sort_order auto, sprint_id param, priority=label
- Update make-plan.md: priority=label, array order = execution order
- Update rest-contract.md: fix sprint-tasks ordering, remove reorder endpoint
- Add ADR-0011: code is bindende volgordesleutel voor stories/taken
- Regenerate docs/INDEX.md via npm run docs
- Remove reorderStoriesAction/reorderTasksAction mocks from backlog tests
- Remove dnd-kit mocks from task-panel test (panel no longer uses dnd)
- Extend materializeIdeaPlanAction test: assert sort_order=parseCodeNumber(code)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
875 lines
31 KiB
TypeScript
875 lines
31 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
|
|
import type {
|
|
SprintWorkspaceSnapshot,
|
|
SprintWorkspaceSprint,
|
|
SprintWorkspaceStory,
|
|
SprintWorkspaceTask,
|
|
SprintWorkspaceTaskDetail,
|
|
} from '@/stores/sprint-workspace/types'
|
|
|
|
// G5: snapshot original actions on module-load; restore in beforeEach.
|
|
const originalActions = (() => {
|
|
const s = useSprintWorkspaceStore.getState()
|
|
return {
|
|
hydrateSnapshot: s.hydrateSnapshot,
|
|
hydrateProductSprints: s.hydrateProductSprints,
|
|
setActiveProduct: s.setActiveProduct,
|
|
setActiveSprint: s.setActiveSprint,
|
|
setActiveStory: s.setActiveStory,
|
|
setActiveTask: s.setActiveTask,
|
|
ensureProductSprintsLoaded: s.ensureProductSprintsLoaded,
|
|
ensureSprintLoaded: s.ensureSprintLoaded,
|
|
ensureStoryLoaded: s.ensureStoryLoaded,
|
|
ensureTaskLoaded: s.ensureTaskLoaded,
|
|
applyRealtimeEvent: s.applyRealtimeEvent,
|
|
resyncActiveScopes: s.resyncActiveScopes,
|
|
resyncLoadedScopes: s.resyncLoadedScopes,
|
|
applyOptimisticMutation: s.applyOptimisticMutation,
|
|
rollbackMutation: s.rollbackMutation,
|
|
settleMutation: s.settleMutation,
|
|
setRealtimeStatus: s.setRealtimeStatus,
|
|
}
|
|
})()
|
|
|
|
function resetStore() {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = null
|
|
s.context.activeSprintId = null
|
|
s.context.activeStoryId = null
|
|
s.context.activeTaskId = null
|
|
s.entities.sprintsById = {}
|
|
s.entities.storiesById = {}
|
|
s.entities.tasksById = {}
|
|
s.relations.sprintIdsByProduct = {}
|
|
s.relations.storyIdsBySprint = {}
|
|
s.relations.taskIdsByStory = {}
|
|
s.loading.loadedProductSprintsIds = {}
|
|
s.loading.loadingProductId = null
|
|
s.loading.loadedSprintIds = {}
|
|
s.loading.loadingSprintId = null
|
|
s.loading.loadedStoryIds = {}
|
|
s.loading.loadedTaskIds = {}
|
|
s.loading.activeRequestId = null
|
|
s.sync.realtimeStatus = 'connecting'
|
|
s.sync.lastEventAt = null
|
|
s.sync.lastResyncAt = null
|
|
s.sync.resyncReason = null
|
|
s.pendingMutations = {}
|
|
Object.assign(s, originalActions)
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetStore()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
function makeSprint(
|
|
overrides: Partial<SprintWorkspaceSprint> & { id: string; product_id: string },
|
|
): SprintWorkspaceSprint {
|
|
return {
|
|
id: overrides.id,
|
|
product_id: overrides.product_id,
|
|
code: overrides.code ?? `S-${overrides.id}`,
|
|
sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`,
|
|
status: overrides.status ?? 'OPEN',
|
|
start_date: overrides.start_date ?? '2026-04-01',
|
|
end_date: overrides.end_date ?? '2026-04-14',
|
|
created_at: overrides.created_at ?? new Date('2026-03-15'),
|
|
completed_at: overrides.completed_at ?? null,
|
|
}
|
|
}
|
|
|
|
function makeStory(
|
|
overrides: Partial<SprintWorkspaceStory> & { id: string; pbi_id: string },
|
|
): SprintWorkspaceStory {
|
|
return {
|
|
id: overrides.id,
|
|
code: overrides.code ?? overrides.id,
|
|
title: overrides.title ?? `Story ${overrides.id}`,
|
|
description: overrides.description ?? null,
|
|
acceptance_criteria: overrides.acceptance_criteria ?? null,
|
|
priority: overrides.priority ?? 2,
|
|
sort_order: overrides.sort_order ?? 1,
|
|
status: overrides.status ?? 'OPEN',
|
|
pbi_id: overrides.pbi_id,
|
|
sprint_id: overrides.sprint_id ?? null,
|
|
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
|
}
|
|
}
|
|
|
|
function makeTask(
|
|
overrides: Partial<SprintWorkspaceTask> & { id: string; story_id: string },
|
|
): SprintWorkspaceTask {
|
|
return {
|
|
id: overrides.id,
|
|
code: overrides.code ?? null,
|
|
title: overrides.title ?? `Task ${overrides.id}`,
|
|
description: overrides.description ?? null,
|
|
priority: overrides.priority ?? 2,
|
|
sort_order: overrides.sort_order ?? 1,
|
|
status: overrides.status ?? 'TO_DO',
|
|
story_id: overrides.story_id,
|
|
sprint_id: overrides.sprint_id ?? null,
|
|
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
|
}
|
|
}
|
|
|
|
function snapshotWith(
|
|
sprint: SprintWorkspaceSprint | undefined,
|
|
stories: SprintWorkspaceStory[] = [],
|
|
tasksByStory: Record<string, SprintWorkspaceTask[]> = {},
|
|
product?: { id: string; name: string },
|
|
): SprintWorkspaceSnapshot {
|
|
return { product, sprint, stories, tasksByStory }
|
|
}
|
|
|
|
// G7/G8: mock fetch via mockImplementation (vers Response per call)
|
|
function mockFetchSequence(
|
|
responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>,
|
|
) {
|
|
let i = 0
|
|
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => {
|
|
const r = responses[Math.min(i, responses.length - 1)]
|
|
i += 1
|
|
const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r
|
|
return new Response(JSON.stringify(body ?? null), { status: 200 })
|
|
}) as unknown as typeof fetch)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// hydrateSnapshot
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('hydrateSnapshot', () => {
|
|
it('vult entities, relations en loaded-marker', () => {
|
|
const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' })
|
|
const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 })
|
|
const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 })
|
|
const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 })
|
|
const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 })
|
|
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
sprint,
|
|
[storyA, storyB],
|
|
{ 's-a': [taskA, taskB] },
|
|
{ id: 'prod-1', name: 'Product 1' },
|
|
),
|
|
)
|
|
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.sprintsById['sp-1']).toEqual(sprint)
|
|
expect(s.entities.storiesById['s-a']).toEqual(storyA)
|
|
expect(s.entities.storiesById['s-b']).toEqual(storyB)
|
|
// sort_order: storyB (1) before storyA (2)
|
|
expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a'])
|
|
// sort_order: taskB (1) before taskA (2)
|
|
expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a'])
|
|
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
|
|
expect(s.loading.loadedSprintIds['sp-1']).toBe(true)
|
|
})
|
|
|
|
it('normaliseert API-statussen naar het interne store-contract', () => {
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })],
|
|
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] },
|
|
),
|
|
)
|
|
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT')
|
|
expect(s.entities.tasksById['t-1'].status).toBe('TO_DO')
|
|
})
|
|
})
|
|
|
|
describe('hydrateProductSprints', () => {
|
|
it('sorteert OPEN voor CLOSED, dan op start_date desc', () => {
|
|
const closedOld = makeSprint({
|
|
id: 'sp-closed-old',
|
|
product_id: 'prod-1',
|
|
status: 'CLOSED',
|
|
start_date: '2026-01-01',
|
|
})
|
|
const openNew = makeSprint({
|
|
id: 'sp-open-new',
|
|
product_id: 'prod-1',
|
|
status: 'OPEN',
|
|
start_date: '2026-04-01',
|
|
})
|
|
const openOld = makeSprint({
|
|
id: 'sp-open-old',
|
|
product_id: 'prod-1',
|
|
status: 'OPEN',
|
|
start_date: '2026-02-01',
|
|
})
|
|
|
|
useSprintWorkspaceStore
|
|
.getState()
|
|
.hydrateProductSprints('prod-1', [closedOld, openOld, openNew])
|
|
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([
|
|
'sp-open-new',
|
|
'sp-open-old',
|
|
'sp-closed-old',
|
|
])
|
|
expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Selection cascade
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('selection cascade', () => {
|
|
it('setActiveSprint reset story+task; setActiveStory reset task', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeSprintId = 'sp-old'
|
|
s.context.activeStoryId = 's-old'
|
|
s.context.activeTaskId = 't-old'
|
|
})
|
|
|
|
useSprintWorkspaceStore.getState().setActiveSprint('sp-new')
|
|
let s = useSprintWorkspaceStore.getState()
|
|
expect(s.context.activeSprintId).toBe('sp-new')
|
|
expect(s.context.activeStoryId).toBeNull()
|
|
expect(s.context.activeTaskId).toBeNull()
|
|
|
|
useSprintWorkspaceStore.setState((draft) => {
|
|
draft.context.activeStoryId = 's-old'
|
|
draft.context.activeTaskId = 't-old'
|
|
})
|
|
useSprintWorkspaceStore.getState().setActiveStory('s-new')
|
|
s = useSprintWorkspaceStore.getState()
|
|
expect(s.context.activeStoryId).toBe('s-new')
|
|
expect(s.context.activeTaskId).toBeNull()
|
|
})
|
|
|
|
it('setActiveProduct(null) ruimt entities en relations op', () => {
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
|
|
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
|
|
{ id: 'prod-1', name: 'Product 1' },
|
|
),
|
|
)
|
|
|
|
useSprintWorkspaceStore.getState().setActiveProduct(null)
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.context.activeProduct).toBeNull()
|
|
expect(s.context.activeSprintId).toBeNull()
|
|
expect(s.entities.sprintsById).toEqual({})
|
|
expect(s.entities.storiesById).toEqual({})
|
|
expect(s.entities.tasksById).toEqual({})
|
|
expect(s.relations.sprintIdsByProduct).toEqual({})
|
|
expect(s.relations.storyIdsBySprint).toEqual({})
|
|
expect(s.relations.taskIdsByStory).toEqual({})
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// applyRealtimeEvent
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('applyRealtimeEvent — sprint', () => {
|
|
beforeEach(() => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
})
|
|
})
|
|
|
|
it('I — voegt sprint toe aan product-lijst', () => {
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'I',
|
|
id: 'sp-new',
|
|
product_id: 'prod-1',
|
|
code: 'S-1',
|
|
sprint_goal: 'New Sprint',
|
|
status: 'OPEN',
|
|
start_date: '2026-05-01',
|
|
end_date: '2026-05-14',
|
|
created_at: new Date('2026-04-15').toISOString(),
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.sprintsById['sp-new']).toBeDefined()
|
|
expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new')
|
|
})
|
|
|
|
it('I — idempotent voor bestaande id', () => {
|
|
useSprintWorkspaceStore
|
|
.getState()
|
|
.hydrateProductSprints('prod-1', [
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }),
|
|
])
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'I',
|
|
id: 'sp-1',
|
|
product_id: 'prod-1',
|
|
sprint_goal: 'echo',
|
|
})
|
|
expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe(
|
|
'Origineel',
|
|
)
|
|
})
|
|
|
|
it('U — patch + her-sorteert', () => {
|
|
useSprintWorkspaceStore
|
|
.getState()
|
|
.hydrateProductSprints('prod-1', [
|
|
makeSprint({
|
|
id: 'sp-a',
|
|
product_id: 'prod-1',
|
|
status: 'OPEN',
|
|
start_date: '2026-04-01',
|
|
}),
|
|
makeSprint({
|
|
id: 'sp-b',
|
|
product_id: 'prod-1',
|
|
status: 'OPEN',
|
|
start_date: '2026-03-01',
|
|
}),
|
|
])
|
|
// sp-a (newer) komt eerst
|
|
expect(
|
|
useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'],
|
|
).toEqual(['sp-a', 'sp-b'])
|
|
|
|
// Sluit sp-a → moet naar achteren
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'U',
|
|
id: 'sp-a',
|
|
product_id: 'prod-1',
|
|
status: 'CLOSED',
|
|
})
|
|
expect(
|
|
useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'],
|
|
).toEqual(['sp-b', 'sp-a'])
|
|
})
|
|
|
|
it('D — verwijdert sprint inclusief child stories en tasks', () => {
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
|
|
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
|
|
{ id: 'prod-1', name: 'Product 1' },
|
|
),
|
|
)
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'D',
|
|
id: 'sp-1',
|
|
product_id: 'prod-1',
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.sprintsById['sp-1']).toBeUndefined()
|
|
expect(s.entities.storiesById['s-1']).toBeUndefined()
|
|
expect(s.entities.tasksById['t-1']).toBeUndefined()
|
|
expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined()
|
|
expect(s.relations.taskIdsByStory['s-1']).toBeUndefined()
|
|
})
|
|
|
|
it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => {
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []),
|
|
)
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeSprintId = 'sp-1'
|
|
s.context.activeStoryId = 's-x'
|
|
s.context.activeTaskId = 't-x'
|
|
})
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'D',
|
|
id: 'sp-1',
|
|
product_id: 'prod-1',
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.context.activeSprintId).toBeNull()
|
|
expect(s.context.activeStoryId).toBeNull()
|
|
expect(s.context.activeTaskId).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('applyRealtimeEvent — story sprint-move', () => {
|
|
beforeEach(() => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
})
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
|
|
),
|
|
)
|
|
})
|
|
|
|
it('U met andere sprint_id verplaatst story uit sprint-relatie', () => {
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'story',
|
|
op: 'U',
|
|
id: 's-1',
|
|
product_id: 'prod-1',
|
|
pbi_id: 'pbi-1',
|
|
sprint_id: 'sp-other',
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.relations.storyIdsBySprint['sp-1']).toEqual([])
|
|
expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1'])
|
|
expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other')
|
|
})
|
|
|
|
it('U met sprint_id=null haalt story uit sprint-relatie', () => {
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'story',
|
|
op: 'U',
|
|
id: 's-1',
|
|
product_id: 'prod-1',
|
|
pbi_id: 'pbi-1',
|
|
sprint_id: null,
|
|
})
|
|
expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([])
|
|
expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('applyRealtimeEvent — task parent-move', () => {
|
|
beforeEach(() => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
})
|
|
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
|
snapshotWith(
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
[
|
|
makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }),
|
|
makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }),
|
|
],
|
|
{
|
|
's-1': [makeTask({ id: 't-1', story_id: 's-1' })],
|
|
's-2': [],
|
|
},
|
|
),
|
|
)
|
|
})
|
|
|
|
it('U met andere story_id verplaatst task naar nieuwe parent', () => {
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'task',
|
|
op: 'U',
|
|
id: 't-1',
|
|
product_id: 'prod-1',
|
|
story_id: 's-2',
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.relations.taskIdsByStory['s-1']).toEqual([])
|
|
expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1'])
|
|
expect(s.entities.tasksById['t-1'].story_id).toBe('s-2')
|
|
})
|
|
})
|
|
|
|
describe('applyRealtimeEvent — andere product genegeerd', () => {
|
|
it('event met ander product_id raakt de store niet', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
})
|
|
useSprintWorkspaceStore
|
|
.getState()
|
|
.hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })])
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'I',
|
|
id: 'sp-other',
|
|
product_id: 'prod-2',
|
|
code: 'X',
|
|
})
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.sprintsById['sp-other']).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('applyRealtimeEvent — unknown entity → resync trigger', () => {
|
|
function withSpy(): ReturnType<typeof vi.fn> {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
})
|
|
const spy = vi.fn().mockResolvedValue(undefined)
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes
|
|
})
|
|
return spy
|
|
}
|
|
|
|
it('unknown entity met matching product triggert resync', () => {
|
|
const spy = withSpy()
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'comment',
|
|
op: 'I',
|
|
id: 'cm-1',
|
|
product_id: 'prod-1',
|
|
})
|
|
expect(spy).toHaveBeenCalledWith('unknown-event')
|
|
})
|
|
|
|
it('unknown entity met ander product_id triggert geen resync', () => {
|
|
const spy = withSpy()
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'comment',
|
|
op: 'I',
|
|
id: 'cm-1',
|
|
product_id: 'prod-2',
|
|
})
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('claude_job_status (type-veld) triggert geen resync', () => {
|
|
const spy = withSpy()
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
type: 'claude_job_status',
|
|
job_id: 'job-1',
|
|
product_id: 'prod-1',
|
|
status: 'queued',
|
|
})
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('worker_heartbeat (type-veld) triggert geen resync', () => {
|
|
const spy = withSpy()
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
type: 'worker_heartbeat',
|
|
worker_id: 'w-1',
|
|
product_id: 'prod-1',
|
|
})
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('payload zonder entity en zonder type wordt genegeerd', () => {
|
|
const spy = withSpy()
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
product_id: 'prod-1',
|
|
something: 'else',
|
|
})
|
|
expect(spy).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// ensure*Loaded
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('ensureProductSprintsLoaded', () => {
|
|
it('fetcht sprint-list en hydreert met sortering', async () => {
|
|
const sprints = [
|
|
makeSprint({
|
|
id: 'sp-old',
|
|
product_id: 'prod-1',
|
|
status: 'CLOSED',
|
|
start_date: '2026-01-01',
|
|
}),
|
|
makeSprint({
|
|
id: 'sp-new',
|
|
product_id: 'prod-1',
|
|
status: 'OPEN',
|
|
start_date: '2026-04-01',
|
|
}),
|
|
]
|
|
const fetchSpy = mockFetchSequence([sprints])
|
|
|
|
await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1')
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'/api/products/prod-1/sprints',
|
|
expect.objectContaining({ cache: 'no-store' }),
|
|
)
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old'])
|
|
expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('ensureSprintLoaded', () => {
|
|
it('fetcht sprint-snapshot en hydreert', async () => {
|
|
const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' })
|
|
const snapshot: SprintWorkspaceSnapshot = {
|
|
product: { id: 'prod-1', name: 'P1' },
|
|
sprint,
|
|
stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })],
|
|
tasksByStory: {
|
|
's-1': [makeTask({ id: 't-1', story_id: 's-1' })],
|
|
},
|
|
}
|
|
const fetchSpy = mockFetchSequence([snapshot])
|
|
|
|
await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1')
|
|
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'/api/sprints/sp-1/workspace',
|
|
expect.objectContaining({ cache: 'no-store' }),
|
|
)
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.entities.sprintsById['sp-1']).toBeDefined()
|
|
expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1'])
|
|
expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1'])
|
|
expect(s.loading.loadedSprintIds['sp-1']).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('race-safe ensure*Loaded — activeRequestId guard', () => {
|
|
it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => {
|
|
let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null
|
|
|
|
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => {
|
|
if (url === '/api/sprints/sp-old/workspace') {
|
|
const snap = await new Promise<SprintWorkspaceSnapshot>((resolve) => {
|
|
resolveOld = resolve
|
|
})
|
|
return new Response(JSON.stringify(snap), { status: 200 })
|
|
}
|
|
if (url === '/api/sprints/sp-new/workspace') {
|
|
return new Response(
|
|
JSON.stringify({
|
|
sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }),
|
|
stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })],
|
|
tasksByStory: {},
|
|
}),
|
|
{ status: 200 },
|
|
)
|
|
}
|
|
return new Response('null', { status: 200 })
|
|
}) as unknown as typeof fetch)
|
|
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
|
s.context.activeSprintId = 'sp-old'
|
|
s.loading.activeRequestId = 'req-old'
|
|
})
|
|
const oldPromise = useSprintWorkspaceStore
|
|
.getState()
|
|
.ensureSprintLoaded('sp-old', 'req-old')
|
|
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeSprintId = 'sp-new'
|
|
s.loading.activeRequestId = 'req-new'
|
|
})
|
|
await useSprintWorkspaceStore
|
|
.getState()
|
|
.ensureSprintLoaded('sp-new', 'req-new')
|
|
|
|
expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined()
|
|
|
|
resolveOld!({
|
|
sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }),
|
|
stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })],
|
|
tasksByStory: {},
|
|
})
|
|
await oldPromise
|
|
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.context.activeSprintId).toBe('sp-new')
|
|
expect(s.entities.storiesById['old-st']).toBeUndefined()
|
|
expect(s.entities.storiesById['new-st']).toBeDefined()
|
|
})
|
|
})
|
|
|
|
describe('ensureTaskLoaded — zet detail-flag', () => {
|
|
it('verrijkt task naar TaskDetail met _detail: true', async () => {
|
|
mockFetchSequence([
|
|
{
|
|
id: 't-1',
|
|
code: 'C1',
|
|
title: 'Task 1',
|
|
description: 'desc',
|
|
priority: 1,
|
|
sort_order: 1,
|
|
status: 'todo',
|
|
story_id: 's-1',
|
|
sprint_id: 'sp-1',
|
|
created_at: new Date('2026-02-01').toISOString(),
|
|
implementation_plan: 'detailed plan here',
|
|
},
|
|
])
|
|
|
|
await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1')
|
|
const task = useSprintWorkspaceStore.getState().entities.tasksById[
|
|
't-1'
|
|
] as SprintWorkspaceTaskDetail
|
|
expect(task._detail).toBe(true)
|
|
expect(task.status).toBe('TO_DO')
|
|
expect(task.implementation_plan).toBe('detailed plan here')
|
|
expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// resyncActiveScopes
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('resyncActiveScopes', () => {
|
|
it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => {
|
|
const fetchSpy = mockFetchSequence([
|
|
// ensureProductSprintsLoaded
|
|
[],
|
|
// ensureSprintLoaded
|
|
{
|
|
sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
|
stories: [],
|
|
tasksByStory: {},
|
|
},
|
|
// ensureStoryLoaded
|
|
[],
|
|
// ensureTaskLoaded
|
|
{
|
|
id: 't-1',
|
|
title: 'T',
|
|
description: null,
|
|
priority: 1,
|
|
sort_order: 1,
|
|
status: 'todo',
|
|
story_id: 's-1',
|
|
sprint_id: 'sp-1',
|
|
created_at: '2026-02-01',
|
|
},
|
|
])
|
|
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
|
s.context.activeSprintId = 'sp-1'
|
|
s.context.activeStoryId = 's-1'
|
|
s.context.activeTaskId = 't-1'
|
|
})
|
|
|
|
await useSprintWorkspaceStore.getState().resyncActiveScopes('manual')
|
|
|
|
const calls = fetchSpy.mock.calls.map(([url]) => url)
|
|
expect(calls).toContain('/api/products/prod-1/sprints')
|
|
expect(calls).toContain('/api/sprints/sp-1/workspace')
|
|
expect(calls).toContain('/api/stories/s-1/tasks')
|
|
expect(calls).toContain('/api/tasks/t-1')
|
|
|
|
const s = useSprintWorkspaceStore.getState()
|
|
expect(s.sync.lastResyncAt).toBeTypeOf('number')
|
|
expect(s.sync.resyncReason).toBe('manual')
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Restore-hint flow
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('restore-hint flow — setters persisteren hints', () => {
|
|
it('setActiveProduct schrijft lastActiveProductId', () => {
|
|
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
|
const raw = localStorage.getItem('sprint-workspace-hints')
|
|
expect(raw).not.toBeNull()
|
|
const hints = JSON.parse(raw!)
|
|
expect(hints.lastActiveProductId).toBe('prod-1')
|
|
})
|
|
|
|
it('setActiveSprint schrijft lastActiveSprintId per product', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
|
|
})
|
|
useSprintWorkspaceStore.getState().setActiveSprint('sp-a')
|
|
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
|
|
expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a')
|
|
})
|
|
|
|
it('setActiveStory schrijft lastActiveStoryId per sprint', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeSprintId = 'sp-1'
|
|
})
|
|
useSprintWorkspaceStore.getState().setActiveStory('s-a')
|
|
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
|
|
expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a')
|
|
})
|
|
|
|
it('setActiveTask schrijft lastActiveTaskId per sprint', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeSprintId = 'sp-1'
|
|
})
|
|
useSprintWorkspaceStore.getState().setActiveTask('t-a')
|
|
const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!)
|
|
expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a')
|
|
})
|
|
})
|
|
|
|
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
|
|
it('hint die NIET in entities zit wordt genegeerd', async () => {
|
|
localStorage.setItem(
|
|
'sprint-workspace-hints',
|
|
JSON.stringify({
|
|
lastActiveProductId: 'prod-1',
|
|
perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } },
|
|
perSprint: {},
|
|
}),
|
|
)
|
|
mockFetchSequence([[]])
|
|
|
|
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
|
await new Promise((r) => setTimeout(r, 20))
|
|
|
|
expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull()
|
|
})
|
|
|
|
it('hint die wel in entities zit wordt toegepast', async () => {
|
|
const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' })
|
|
localStorage.setItem(
|
|
'sprint-workspace-hints',
|
|
JSON.stringify({
|
|
lastActiveProductId: 'prod-1',
|
|
perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } },
|
|
perSprint: {},
|
|
}),
|
|
)
|
|
mockFetchSequence([
|
|
// ensureProductSprintsLoaded — levert sp-known
|
|
[validSprint],
|
|
// ensureSprintLoaded triggered door setActiveSprint(hint)
|
|
{ sprint: validSprint, stories: [], tasksByStory: {} },
|
|
])
|
|
|
|
useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
|
await new Promise((r) => setTimeout(r, 30))
|
|
|
|
expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known')
|
|
})
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Optimistic mutations
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('optimistic mutations', () => {
|
|
it('SSE-echo van een al-bestaande sprint is idempotent', () => {
|
|
useSprintWorkspaceStore.setState((s) => {
|
|
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
|
})
|
|
useSprintWorkspaceStore
|
|
.getState()
|
|
.hydrateProductSprints('prod-1', [
|
|
makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }),
|
|
])
|
|
useSprintWorkspaceStore.getState().applyRealtimeEvent({
|
|
entity: 'sprint',
|
|
op: 'I',
|
|
id: 'sp-1',
|
|
product_id: 'prod-1',
|
|
sprint_goal: 'echo',
|
|
})
|
|
expect(
|
|
useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal,
|
|
).toBe('Origineel')
|
|
})
|
|
})
|