feat(PBI-74): Zustand product-workspace rearchitecture (Stories 1-8) (#180)

* feat(PBI-74): product-workspace store skelet + test-infra (Story 1)

Skelet voor de nieuwe `product-workspace-store` die op termijn de gefragmenteerde
`backlog-store`/`planner-store`/`selection-store`/`product-store` vervangt. Deze
PR levert alleen het skelet + tests; UI-consumers worden in latere stories
omgezet.

- vitest naar jsdom + tests/setup.ts (MemoryStorage, default fetch-stub) — G6/G8
- stores/product-workspace/{types,store,selectors,restore}.ts — immer-middleware,
  alle slices en acties (hydrate, setActive*, ensure*Loaded met activeRequestId-
  guard, applyRealtimeEvent, resyncActiveScopes/loadedScopes, optimistic
  mutations). Restore-wiring in setters volgt in Story 4 (T-857/T-858).
- selectors gebruiken module-level EMPTY refs (G1) en documenteren useShallow-
  vereiste (G2)
- 34 nieuwe unit-tests dekken §Testing setup-checklist uit het ontwerp:
  hydrateSnapshot, selection-cascade, applyRealtimeEvent (I/U/D + parent-move +
  ander-product + unknown-entity → resync), delete-cleanup, race-safe loaders,
  ensureTaskLoaded _detail-flag, resyncActiveScopes ensure-keten, restore-hints
  read/write/clear, optimistic mutation rollback/settle/SSE-echo idempotent
- docs/api/rest-contract.md: audit-sectie met de vier ontbrekende
  ensure*Loaded-endpoints (worden toegevoegd in Story 7 / T-870)

Refs: PBI-74, ST-1318, T-837..T-843
Bron-ontwerp: docs/plans/zustand-store-rearchitecture.md
Implementatieplan: docs/plans/zustand-workspace-store-implementation.md

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

* feat(PBI-74): dual-dispatch hydratie + realtime naar workspace-store (Story 2)

Story 2 — schaduw-fase: BacklogHydrationWrapper en useBacklogRealtime voeden
nu ook de nieuwe product-workspace-store, terwijl de oude useBacklogStore /
useProductStore leidend blijft voor componenten. Story 3 verschuift consumers
één voor één; Story 8 ruimt de oude stores op.

- T-844: BacklogHydrationWrapper roept naast useBacklogStore.setInitialData
  ook useProductWorkspaceStore.hydrateSnapshot aan. Productname-prop optioneel
  toegevoegd voor activeProduct-context.
- T-845: useBacklogRealtime onmessage dispatcht events naar zowel oude store
  (applyChange) als nieuwe store (applyRealtimeEvent). Geen wijziging aan
  reconnect/visibility — Story 5.
- T-846: dev-only logWorkspaceFingerprint helper vergelijkt counts tussen
  oude en nieuwe store na hydrate en na elk realtime-event. console.warn bij
  mismatch; opt-in debug log via NEXT_PUBLIC_DEBUG_WORKSPACE_FINGERPRINT=1.
  Bestand TODO-marked voor verwijdering in Story 8 (T-878).
- T-847: SetCurrentProduct schrijft naast oude useProductStore ook
  useProductWorkspaceStore.setActiveProduct({id, name}); cleanup cleart beide.
  setActiveProduct triggert ensureProductLoaded — fetch-stub tot Story 7
  (T-870) de LIST-endpoints toevoegt.

Verify: lint+typecheck clean, 636/636 tests groen (geen UI-regressie omdat
oude store leidend blijft).

Refs: PBI-74, ST-1319, T-844..T-847

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

* feat(PBI-74): migreer backlog-componenten naar workspace-store (Story 3)

Story 3 verplaatst alle UI-consumers van de oude vier stores
(useBacklogStore/usePlannerStore/useSelectionStore/useProductStore) naar de
nieuwe product-workspace-store. De oude stores blijven nog bestaan voor
hydration-wrapper en realtime-hook (dual-dispatch); Story 8 ruimt ze op.

- T-848 backlog-split-pane.tsx: leest activePbiId/activeStoryId uit
  context-slice (primitives, geen useShallow nodig).
- T-849 pbi-list.tsx: selectVisiblePbis(useShallow); DnD via
  applyOptimisticMutation('pbi-order' + optionele 'entity-patch' bij
  cross-priority drag), met settle/rollback per server-result.
- T-850 story-panel.tsx: selectStoriesForActivePbi(useShallow); DnD via
  applyOptimisticMutation('story-order' + entity-patch bij priority change).
- T-851 task-panel.tsx: selectTasksForActiveStory(useShallow); DnD via
  applyOptimisticMutation('task-order'); detail-view (ensureTaskLoaded +
  isDetail) zit in de task-dialog (apart component, niet in deze lijst).
- T-852 start-sprint-button.tsx: selectActivePbi + selectStoriesForActivePbi
  voor free-story count.
- T-853 set-current-product.tsx: alleen workspace-store.setActiveProduct
  (oude useProductStore-import verwijderd).
- T-854 G1/G2-audit: alle nieuwe selectors gebruiken module-level EMPTY
  refs (G1) en useShallow voor lijsten (G2). Geen 'Maximum update depth'-
  warnings tijdens npm test.
- T-855 tests bijgewerkt: backlog-split-pane.test, task-panel.test,
  integration.test gebruiken nu setState op workspace-store (helpers
  resetWorkspace/setActiveStoryAndTasks/selectPbi/selectStory).

Verify: lint+typecheck clean, 636/636 tests groen. UI-consumers van
oude stores zijn nu nul (uitgezonderd dual-dispatch in hydration-wrapper en
realtime-hook + dev-fingerprint-helper, die in Story 8/T-873/T-878 verdwijnen).

Refs: PBI-74, ST-1320, T-848..T-855

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

* feat(PBI-74): race-safe loaders + restore-hints + URL-prioriteit (Story 4)

- T-856: activeRequestId-guard zat al in store.ts uit Story 1; bevestigd door
  de race-safety test (in-flight ensurePbiLoaded mag niet overschrijven).
- T-857: restore-hint flow toegevoegd in setActiveProduct/setActivePbi/
  setActiveStory. Async chain: await ensureXxxLoaded → guard check →
  readHints → valideer hint via entities.byId → setActiveYyy(hint).
  Geen setTimeout-trick — chain is alleen await-based.
- T-858: writeProductHint/writePbiHint/writeStoryHint/writeTaskHint
  aangeroepen direct na set(...) zodat de hint-persistentie altijd
  consistent is met de in-store selectie.
- T-859: nieuwe components/backlog/url-task-sync.tsx — leest
  ?editTask=&lt;id&gt; uit useSearchParams, schrijft de hint en roept
  setActiveTask aan zodat de URL wint boven een eerder gepersisteerde
  task-hint. Gemount in beide product-pages (desktop + mobile) binnen
  BacklogHydrationWrapper.
- T-860: 6 nieuwe vitest-cases — 4 voor hint-persist per setter, 2 voor de
  restore-flow chain (hint die niet in entities zit wordt genegeerd; hint
  die wel in entities zit wordt toegepast). Bestaande race-safety test
  blijft groen.

Verify: lint+typecheck clean, 642/642 tests groen.

Refs: PBI-74, ST-1321, T-856..T-860

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

* feat(PBI-74): hidden-tab + reconnect resync (Story 5)

Per ontwerp samen in één commit zodat geen vangnet wegvalt zonder vervanging.

- T-861: useBacklogRealtime sluit niet meer op visibilitychange hidden;
  EventSource blijft open zolang browser/netwerk dit toelaten. Reconnect bij
  netwerkfout blijft via backoff. visibilitychange fungeert nog wel als
  re-connect-trigger als de stream tussentijds is gesloten (b.v. 240s
  hard-close server-side).
- T-862: 'ready'-event-handler telt connect-cycles. De eerste 'ready' is de
  initial connect (geen resync). Bij latere 'ready' (post-reconnect) wordt
  resyncActiveScopes('reconnect') aangeroepen om gemiste events op te halen.
- T-863: nieuwe lib/realtime/use-workspace-resync.ts — luistert op
  document.visibilitychange (hidden→visible) en window.online; dispatcht
  resyncActiveScopes('visible') resp. 'reconnect'. Mounted in
  BacklogHydrationWrapper na useBacklogRealtime.
- T-864: 4 nieuwe vitest-cases voor useWorkspaceResync (jsdom): visible→
  visible event, online event, hidden negeren, cleanup-bij-unmount.

Daarnaast lint-cleanup: ongebruikte 'order'-variabelen in pbi-list en
story-panel weggehaald.

Verify: lint+typecheck clean, 646/646 tests groen.

Refs: PBI-74, ST-1322, T-861..T-864

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

* feat(PBI-74): unknown-event fallback tests (Story 6)

T-865 (isUnknownEntityEvent filter) en T-866 (resync-trigger in
applyRealtimeEvent) zijn al in Story 1 geïmplementeerd in store.ts;
deze story breidt de test-coverage uit met expliciete negatieve cases
voor het type-veld noise pattern.

T-867 — 5 nieuwe vitest-cases:
- unknown entity met ANDER product_id → geen resync
- claude_job_status (type) → geen resync
- worker_heartbeat (type) → geen resync
- claude_job_enqueued (type) → geen resync
- payload zonder entity en zonder type → genegeerd
- question-entity (entity-veld, geen type, niet pbi/story/task) → resync trigger

Verify: lint+typecheck clean, 651/651 tests groen.

Refs: PBI-74, ST-1323, T-865..T-867

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

* feat(PBI-74): cache-headers + LIST endpoints (Story 7)

- T-868: cache: 'no-store' was al ingebouwd in fetchJson helper (Story 1).
  Bevestigd door bestaande ensureProductLoaded test die de fetch-init
  controleert.
- T-869: force-dynamic toegevoegd op alle vier nieuwe LIST-endpoints.
- T-870: vier nieuwe routes voor ensure*Loaded:
  - GET /api/products/:id/backlog → ProductBacklogSnapshot
  - GET /api/pbis/:id/stories → BacklogStory[]
  - GET /api/stories/:id/tasks → BacklogTask[]
  - GET /api/tasks/:id (nieuwe handler naast bestaande PATCH) → TaskDetail
    met _detail: true marker
  Auth via authenticateApiRequest (Bearer of iron-session); access-control
  via productAccessFilter (gebruiker is owner of member van het product).
  Statussen worden via taskStatusToApi/storyStatusToApi/pbiStatusToApi
  vertaald naar lowercase API-vorm.
- T-871: SSE-route /api/realtime/backlog stuurt al ready-event direct na
  LISTEN (regel 106) — geen wijziging nodig.

Verify: lint+typecheck clean, 651/651 tests groen.

Refs: PBI-74, ST-1324, T-868..T-871

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

* feat(PBI-74): oude stores opruimen (Story 8)

Workspace-store is nu de enige bron voor product-backlog client-state. De
vier voorgangers en de dual-dispatch-infrastructuur zijn verwijderd.

- T-872: grep over codebase op useBacklogStore/usePlannerStore/
  useSelectionStore/useProductStore is leeg.
- T-873..T-876: stores/{backlog,planner,selection,product}-store.ts deleted.
- T-877: __tests__/realtime/payload-contract.test.ts en
  __tests__/api/backlog-realtime.test.ts deleted — pbi/story/task I|U|D
  payload-handling wordt al gedekt door
  __tests__/stores/product-workspace/store.test.ts (incl. parent-move,
  idempotent inserts, delete-cleanup).
- T-878: lib/realtime/dev-workspace-fingerprint.ts deleted, dual-dispatch
  uit BacklogHydrationWrapper en lib/realtime/use-backlog-realtime.ts
  weggehaald. stores/products-store.ts (lijst van producten ≠ active
  product) blijft ongewijzigd.

Bijwerkingen:
- BacklogPbi en BacklogStory types in components/backlog/story-panel.tsx en
  components/sprint/sprint-backlog.tsx krijgen sort_order zodat ze met de
  workspace-types overeenkomen.
- Server-pages /products/[id]/page.tsx (desktop+mobile) en
  /products/[id]/sprint/[sprintId]/page.tsx selecteren sort_order op story
  en mappen het door in de hydration-payload.

Verify: lint+typecheck clean, 626/626 tests groen (verlies van 25 redundante
oude-store tests; workspace-store tests dekken hetzelfde gedrag).

Refs: PBI-74, ST-1325, T-872..T-878

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

* docs(PBI-74): richtlijn workspace-store + realtime patroon

Documenteert het patroon dat in Stories 1-8 is opgeleverd, zodat een
volgende workspace-store (sprint, of een nieuwe bounded context) hetzelfde
recept volgt.

- docs/patterns/workspace-store.md (nieuw): wanneer een workspace-store, de
  vijf state-slices, selectors-regels (G1/G2), race-safe ensure*Loaded met
  activeRequestId-guard (G4), SSE-hook + applyRealtimeEvent met
  unknown-event filter, hidden-tab + reconnect resync via
  useWorkspaceResync, restore-hint flow met await-chain en URL-prioriteit,
  optimistic mutations (applyOptimisticMutation/rollback/settle), API
  endpoint-vereisten (force-dynamic, cache: no-store), test-setup met
  MemoryStorage + originalActions snapshot + mockImplementation, gotchas
  G1-G8 als comment-template, en het 8-staps migratiepad.
- docs/patterns/zustand-optimistic.md: bijgewerkt voor de nieuwe
  workspace-store API; verwijst voor het bredere patroon naar
  workspace-store.md. Voorbeelden voor pbi-order + entity-patch.
- CLAUDE.md: patterns quickref aangevuld met workspace-store-rij.

Verify: typecheck clean.

Refs: PBI-74

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

* fix(PBI-74): solo + notifications hooks volgen ook hidden-tab/resync patroon

Het uitgangspunt van PBI-74 (robuust tegen gemiste SSE-events, hidden tabs
en onbekende notify-vormen) gold universeel — niet alleen voor
product-workspace. use-solo-realtime en use-notifications-realtime hadden
nog dezelfde bug die use-backlog-realtime in Story 5 al opgelost kreeg:
sluit stream op hidden, geen resync.

Reproductie (zoals gemeld): solo-screen open in tab A, product-backlog
open in tab B; bewerk task-title in tab B → tab A's solo-SSE was gesloten
(hidden) en kreeg het NOTIFY-event nooit. Tab terug naar solo →
EventSource reconnect maar geen resync → oude title persisteert. Postgres
NOTIFY heeft geen replay, dus zonder resync zijn die events permanent
verloren.

Fix in beide hooks (zelfde patroon als Story 5 voor backlog):
- Stream blijft open op visibilitychange hidden — geen close() meer.
- Bij hidden→visible én bij window 'online': router.refresh() zodat de
  server-component opnieuw fetcht en de initial-state-prop ververst (wat
  voor solo de tasks-record reset via initTasks; voor notifications de
  questions-bel-state).
- Bij latere 'ready'-events na reconnect (use-solo-realtime): zelfde
  router.refresh() trigger zodat we niet vertrouwen op alleen het
  visibility-pad.

Verify: lint + typecheck clean, 626/626 tests groen.

Refs: PBI-74

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

* docs: fix broken links in research-repo plan

docs/plans/lees-de-readme-md-validated-book.md beschrijft een research-
repo migratiepad. De links waren geschreven vanuit het research-repo-
perspectief (paden als stores/data-store.ts, ../Scrum4Me/CLAUDE.md,
docs/plans/zustand-store-rearchitecture.md zonder relative-prefix), wat
de doc-link-checker hier laat falen.

- Header-note toegevoegd dat het document voor de research-repo is.
- Interne refs (zustand-store-rearchitecture.md, CLAUDE.md) → relatieve
  paden die in deze repo wél resolven (./zustand-..., ../../CLAUDE.md).
- Research-repo-only refs (stores/data-store.ts,
  hooks/use-event-stream.ts, components/*-select.tsx, etc.) → inline
  code-tags met "(research-repo)" suffix; de link-checker slaat ze over
  en de leesbaarheid blijft.

Verify: npm run docs:check-links → ✓ All doc links valid (118 files).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 02:25:19 +02:00 committed by GitHub
parent 0d126695db
commit 5df04feb11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 3736 additions and 736 deletions

View file

@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import {
clearHints,
readHints,
writePbiHint,
writeProductHint,
writeStoryHint,
writeTaskHint,
} from '@/stores/product-workspace/restore'
describe('readHints', () => {
it('retourneert lege defaults wanneer localStorage leeg is', () => {
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
it('herstelt hints uit localStorage', () => {
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'p1',
perProduct: { p1: { lastActivePbiId: 'pbi-1' } },
}),
)
const hints = readHints()
expect(hints.lastActiveProductId).toBe('p1')
expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1')
})
it('valt terug op defaults bij ongeldige JSON', () => {
localStorage.setItem('product-workspace-hints', '{not-json')
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
it('valt terug op defaults bij verkeerde shape', () => {
localStorage.setItem('product-workspace-hints', '"just a string"')
const hints = readHints()
expect(hints.perProduct).toEqual({})
})
})
describe('writeProductHint', () => {
it('schrijft lastActiveProductId', () => {
writeProductHint('p1')
expect(readHints().lastActiveProductId).toBe('p1')
})
it('overschrijft bestaande waarde', () => {
writeProductHint('p1')
writeProductHint('p2')
expect(readHints().lastActiveProductId).toBe('p2')
})
it('accepteert null om hint te wissen', () => {
writeProductHint('p1')
writeProductHint(null)
expect(readHints().lastActiveProductId).toBeNull()
})
})
describe('writePbiHint', () => {
it('schrijft lastActivePbiId per productId', () => {
writePbiHint('prod-1', 'pbi-a')
writePbiHint('prod-2', 'pbi-b')
const hints = readHints()
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b')
})
it('null wist child story- en task-hints', () => {
writePbiHint('prod-1', 'pbi-1')
writeStoryHint('prod-1', 's-1')
writeTaskHint('prod-1', 't-1')
writePbiHint('prod-1', null)
const hints = readHints()
expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull()
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull()
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull()
})
})
describe('writeStoryHint', () => {
it('schrijft lastActiveStoryId per productId', () => {
writeStoryHint('prod-1', 's-1')
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1')
})
it('null wist child task-hint', () => {
writeStoryHint('prod-1', 's-1')
writeTaskHint('prod-1', 't-1')
writeStoryHint('prod-1', null)
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull()
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull()
})
})
describe('writeTaskHint', () => {
it('schrijft lastActiveTaskId per productId', () => {
writeTaskHint('prod-1', 't-1')
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1')
})
})
describe('clearHints', () => {
it('verwijdert alle hints', () => {
writeProductHint('p1')
writePbiHint('p1', 'pbi-1')
clearHints()
const hints = readHints()
expect(hints.lastActiveProductId).toBeNull()
expect(hints.perProduct).toEqual({})
})
})

View file

@ -0,0 +1,832 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type {
BacklogPbi,
BacklogStory,
BacklogTask,
ProductBacklogSnapshot,
TaskDetail,
} from '@/stores/product-workspace/types'
// G5: snapshot original actions on module-load; restore in beforeEach.
// vi.fn-spies on actions could leak across tests otherwise.
const originalActions = (() => {
const s = useProductWorkspaceStore.getState()
return {
hydrateSnapshot: s.hydrateSnapshot,
setActiveProduct: s.setActiveProduct,
setActivePbi: s.setActivePbi,
setActiveStory: s.setActiveStory,
setActiveTask: s.setActiveTask,
ensureProductLoaded: s.ensureProductLoaded,
ensurePbiLoaded: s.ensurePbiLoaded,
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() {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = null
s.context.activePbiId = null
s.context.activeStoryId = null
s.context.activeTaskId = null
s.entities.pbisById = {}
s.entities.storiesById = {}
s.entities.tasksById = {}
s.relations.pbiIds = []
s.relations.storyIdsByPbi = {}
s.relations.taskIdsByStory = {}
s.loading.loadedProductId = null
s.loading.loadingProductId = null
s.loading.loadedPbiIds = {}
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 makePbi(overrides: Partial<BacklogPbi> & { id: string }): BacklogPbi {
return {
id: overrides.id,
code: overrides.code ?? overrides.id,
title: overrides.title ?? `PBI ${overrides.id}`,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
description: overrides.description ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'),
status: overrides.status ?? 'ready',
}
}
function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: string }): BacklogStory {
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<BacklogTask> & { id: string; story_id: string }): BacklogTask {
return {
id: overrides.id,
title: overrides.title ?? `Task ${overrides.id}`,
description: overrides.description ?? null,
priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'todo',
story_id: overrides.story_id,
created_at: overrides.created_at ?? new Date('2026-01-01'),
}
}
function snapshotWith(
pbis: BacklogPbi[],
storiesByPbi: Record<string, BacklogStory[]> = {},
tasksByStory: Record<string, BacklogTask[]> = {},
product?: { id: string; name: string },
): ProductBacklogSnapshot {
return { product, pbis, storiesByPbi, tasksByStory }
}
// G7: mock fetch — never let it fall through to real network
// G8: mockImplementation per call so each fetch gets a fresh Response
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 en relations met gesorteerde id-lijsten', () => {
const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 })
const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 })
const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 })
const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 })
const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 })
const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 })
const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 })
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[pbiA, pbiB, pbiC],
{ 'pbi-b': [storyB1, storyB2] },
{ 'st-1': [taskA, taskB] },
{ id: 'prod-1', name: 'Product 1' },
),
)
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['pbi-a']).toBe(pbiA)
expect(s.entities.pbisById['pbi-b']).toBe(pbiB)
expect(s.entities.pbisById['pbi-c']).toBe(pbiC)
// pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2)
expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a'])
expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1'])
expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2'])
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
expect(s.loading.loadedProductId).toBe('prod-1')
})
it('reset bestaande entities en relations bij her-hydratie', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'old-pbi' })]),
)
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi'])
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'new-pbi' })]),
)
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['old-pbi']).toBeUndefined()
expect(s.entities.pbisById['new-pbi']).toBeDefined()
expect(s.relations.pbiIds).toEqual(['new-pbi'])
})
})
// ─────────────────────────────────────────────────────────────────────────
// Selection cascade
// ─────────────────────────────────────────────────────────────────────────
describe('selection cascade', () => {
it('setActivePbi reset story+task; setActiveStory reset task', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'pbi-old'
s.context.activeStoryId = 'st-old'
s.context.activeTaskId = 'tk-old'
})
useProductWorkspaceStore.getState().setActivePbi('pbi-new')
let s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBe('pbi-new')
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
useProductWorkspaceStore.setState((draft) => {
draft.context.activeStoryId = 'st-old'
draft.context.activeTaskId = 'tk-old'
})
useProductWorkspaceStore.getState().setActiveStory('st-new')
s = useProductWorkspaceStore.getState()
expect(s.context.activeStoryId).toBe('st-new')
expect(s.context.activeTaskId).toBeNull()
})
it('setActiveProduct(null) ruimt entities en relations op', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p-1' })],
{ 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] },
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
{ id: 'prod-1', name: 'Product 1' },
),
)
useProductWorkspaceStore.getState().setActiveProduct(null)
const s = useProductWorkspaceStore.getState()
expect(s.context.activeProduct).toBeNull()
expect(s.context.activePbiId).toBeNull()
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
expect(s.entities.pbisById).toEqual({})
expect(s.entities.storiesById).toEqual({})
expect(s.entities.tasksById).toEqual({})
expect(s.relations.pbiIds).toEqual([])
expect(s.relations.storyIdsByPbi).toEqual({})
expect(s.relations.taskIdsByStory).toEqual({})
expect(s.loading.loadedProductId).toBeNull()
})
})
// ─────────────────────────────────────────────────────────────────────────
// applyRealtimeEvent
// ─────────────────────────────────────────────────────────────────────────
describe('applyRealtimeEvent — pbi', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
})
it('I — voegt PBI toe en sorteert', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'b',
product_id: 'prod-1',
code: 'B',
title: 'New PBI',
priority: 1,
sort_order: 1,
created_at: new Date('2026-02-01').toISOString(),
status: 'ready',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['b']).toBeDefined()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
})
it('I — idempotent voor bestaande id', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'a',
product_id: 'prod-1',
title: 'mutated',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven
expect(s.relations.pbiIds).toEqual(['a'])
})
it('U — patch + her-sorteert', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'U',
id: 'b',
product_id: 'prod-1',
priority: 1,
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
})
it('D — verwijdert PBI inclusief child stories en tasks', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' })],
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' })] },
{ s1: [makeTask({ id: 't1', story_id: 's1' })] },
),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'D',
id: 'p1',
product_id: 'prod-1',
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['p1']).toBeUndefined()
expect(s.entities.storiesById['s1']).toBeUndefined()
expect(s.entities.tasksById['t1']).toBeUndefined()
expect(s.relations.pbiIds).toEqual([])
expect(s.relations.storyIdsByPbi['p1']).toBeUndefined()
expect(s.relations.taskIdsByStory['s1']).toBeUndefined()
})
it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'p1' })]),
)
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'p1'
s.context.activeStoryId = 's-x'
s.context.activeTaskId = 't-x'
})
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'D',
id: 'p1',
product_id: 'prod-1',
})
const s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBeNull()
expect(s.context.activeStoryId).toBeNull()
expect(s.context.activeTaskId).toBeNull()
})
})
describe('applyRealtimeEvent — story parent-move', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' }), makePbi({ id: 'p2' })],
{
p1: [makeStory({ id: 's1', pbi_id: 'p1' })],
p2: [],
},
),
)
})
it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => {
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'story',
op: 'U',
id: 's1',
product_id: 'prod-1',
pbi_id: 'p2',
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.storyIdsByPbi['p1']).toEqual([])
expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1'])
expect(s.entities.storiesById['s1'].pbi_id).toBe('p2')
})
})
describe('applyRealtimeEvent — task parent-move', () => {
beforeEach(() => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith(
[makePbi({ id: 'p1' })],
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] },
{
s1: [makeTask({ id: 't1', story_id: 's1' })],
s2: [],
},
),
)
})
it('U met andere story_id verplaatst task naar nieuwe parent', () => {
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'task',
op: 'U',
id: 't1',
product_id: 'prod-1',
story_id: 's2',
})
const s = useProductWorkspaceStore.getState()
expect(s.relations.taskIdsByStory['s1']).toEqual([])
expect(s.relations.taskIdsByStory['s2']).toEqual(['t1'])
expect(s.entities.tasksById['t1'].story_id).toBe('s2')
})
})
describe('applyRealtimeEvent — andere product genegeerd', () => {
it('event met ander product_id raakt de store niet', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'b',
product_id: 'prod-2',
title: 'Other product',
priority: 1,
sort_order: 1,
})
const s = useProductWorkspaceStore.getState()
expect(s.entities.pbisById['b']).toBeUndefined()
expect(s.relations.pbiIds).toEqual(['a'])
})
})
describe('applyRealtimeEvent — unknown entity → resync trigger', () => {
function withSpy(): ReturnType<typeof vi.fn> {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
})
const spy = vi.fn().mockResolvedValue(undefined)
useProductWorkspaceStore.setState((s) => {
s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes
})
return spy
}
it('unknown entity (b.v. comment) met matching product triggert resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).toHaveBeenCalledWith('unknown-event')
})
it('unknown entity met ander product_id triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'comment',
op: 'I',
id: 'cm-1',
product_id: 'prod-2',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('claude_job_status (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'claude_job_status',
job_id: 'job-1',
product_id: 'prod-1',
status: 'queued',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('worker_heartbeat (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'worker_heartbeat',
worker_id: 'w-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('claude_job_enqueued (type-veld) triggert geen resync', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
type: 'claude_job_enqueued',
job_id: 'job-2',
product_id: 'prod-1',
kind: 'PER_TASK',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('payload zonder entity en zonder type wordt genegeerd', () => {
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
product_id: 'prod-1',
something: 'else',
} as unknown as Record<string, unknown>)
expect(spy).not.toHaveBeenCalled()
})
it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => {
// question is geen pbi/story/task entity dus telt als unknown wanneer
// hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die
// we nog niet kennen.
const spy = withSpy()
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'question',
op: 'I',
id: 'q-1',
product_id: 'prod-1',
} as unknown as Record<string, unknown>)
expect(spy).toHaveBeenCalledWith('unknown-event')
})
})
// ─────────────────────────────────────────────────────────────────────────
// ensure*Loaded fetches + race-safe guard + sortering
// ─────────────────────────────────────────────────────────────────────────
describe('ensureProductLoaded', () => {
it('fetcht backlog snapshot en hydreert met sortering', async () => {
const snapshot: ProductBacklogSnapshot = {
product: { id: 'prod-1', name: 'Product 1' },
pbis: [
makePbi({ id: 'a', priority: 2, sort_order: 5 }),
makePbi({ id: 'b', priority: 1, sort_order: 9 }),
],
storiesByPbi: {},
tasksByStory: {},
}
const fetchSpy = mockFetchSequence([snapshot])
await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/prod-1/backlog',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useProductWorkspaceStore.getState()
expect(s.relations.pbiIds).toEqual(['b', 'a'])
expect(s.loading.loadedProductId).toBe('prod-1')
expect(s.loading.loadedPbiIds['a']).toBe(true)
expect(s.loading.loadedPbiIds['b']).toBe(true)
})
})
describe('race-safe ensure*Loaded — activeRequestId guard', () => {
it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => {
let resolveOld: ((stories: BacklogStory[]) => void) | null = null
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => {
if (url === '/api/pbis/pbi-old/stories') {
const stories = await new Promise<BacklogStory[]>((resolve) => {
resolveOld = resolve
})
return new Response(JSON.stringify(stories), { status: 200 })
}
if (url === '/api/pbis/pbi-new/stories') {
return new Response(
JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]),
{ status: 200 },
)
}
return new Response('null', { status: 200 })
}) as unknown as typeof fetch)
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
s.context.activePbiId = 'pbi-old'
s.loading.activeRequestId = 'req-old'
})
const oldPromise = useProductWorkspaceStore
.getState()
.ensurePbiLoaded('pbi-old', 'req-old')
// gebruiker selecteert ondertussen pbi-new
useProductWorkspaceStore.setState((s) => {
s.context.activePbiId = 'pbi-new'
s.loading.activeRequestId = 'req-new'
})
await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new')
expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined()
// resolve de oude fetch — guard moet de stale data weigeren
resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })])
await oldPromise
const s = useProductWorkspaceStore.getState()
expect(s.context.activePbiId).toBe('pbi-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',
title: 'Task 1',
description: 'desc',
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
created_at: new Date('2026-02-01').toISOString(),
implementation_plan: 'detailed plan here',
},
])
await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1')
const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail
expect(task._detail).toBe(true)
expect(task.implementation_plan).toBe('detailed plan here')
expect(useProductWorkspaceStore.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([
// ensureProductLoaded
{ product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
// ensurePbiLoaded
[],
// ensureStoryLoaded
[],
// ensureTaskLoaded
{
id: 't-1',
title: 'T',
description: null,
priority: 1,
sort_order: 1,
status: 'todo',
story_id: 's-1',
created_at: '2026-02-01',
},
])
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
s.context.activePbiId = 'pbi-1'
s.context.activeStoryId = 's-1'
s.context.activeTaskId = 't-1'
})
await useProductWorkspaceStore.getState().resyncActiveScopes('manual')
const calls = fetchSpy.mock.calls.map(([url]) => url)
expect(calls).toContain('/api/products/prod-1/backlog')
expect(calls).toContain('/api/pbis/pbi-1/stories')
expect(calls).toContain('/api/stories/s-1/tasks')
expect(calls).toContain('/api/tasks/t-1')
const s = useProductWorkspaceStore.getState()
expect(s.sync.lastResyncAt).toBeTypeOf('number')
expect(s.sync.resyncReason).toBe('manual')
})
})
// ─────────────────────────────────────────────────────────────────────────
// Optimistic mutations
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// Restore-hint integratie (Story 4)
// ─────────────────────────────────────────────────────────────────────────
describe('restore-hint flow — setters persisteren hints', () => {
it('setActiveProduct schrijft lastActiveProductId', () => {
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
const raw = localStorage.getItem('product-workspace-hints')
expect(raw).not.toBeNull()
const hints = JSON.parse(raw!)
expect(hints.lastActiveProductId).toBe('prod-1')
})
it('setActivePbi schrijft lastActivePbiId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActivePbi('pbi-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
})
it('setActiveStory schrijft lastActiveStoryId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveStory('story-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a')
})
it('setActiveTask schrijft lastActiveTaskId per product', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
})
useProductWorkspaceStore.getState().setActiveTask('task-a')
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a')
})
})
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
it('hint die NIET in entities zit wordt genegeerd', async () => {
// Schrijf een hint voor een PBI die niet bestaat
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } },
}),
)
// Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen
// ghost-pbi in entities.
mockFetchSequence([
{ product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
// Wacht tot async restore-flow afgewikkeld is.
await new Promise((r) => setTimeout(r, 20))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull()
})
it('hint die wel in entities zit wordt toegepast', async () => {
const validPbi = makePbi({ id: 'pbi-known' })
localStorage.setItem(
'product-workspace-hints',
JSON.stringify({
lastActiveProductId: 'prod-1',
perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } },
}),
)
mockFetchSequence([
// ensureProductLoaded levert pbi-known
{
product: { id: 'prod-1', name: 'P1' },
pbis: [validPbi],
storiesByPbi: {},
tasksByStory: {},
},
// ensurePbiLoaded triggered door setActivePbi(hint) — geen stories
[],
])
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
await new Promise((r) => setTimeout(r, 30))
expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known')
})
})
describe('optimistic mutations', () => {
it('rollback herstelt vorige pbi-order', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
]),
)
const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds]
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
kind: 'pbi-order',
prevPbiIds: prevOrder,
})
// simuleer de optimistic order-wijziging buiten de mutation
useProductWorkspaceStore.setState((s) => {
s.relations.pbiIds = ['b', 'a']
})
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a'])
useProductWorkspaceStore.getState().rollbackMutation(id)
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder)
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
})
it('settle ruimt pending op zonder state te wijzigen', () => {
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a' })]),
)
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
kind: 'pbi-order',
prevPbiIds: ['a'],
})
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined()
useProductWorkspaceStore.getState().settleMutation(id)
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
})
it('SSE-echo van een al-bestaande PBI is idempotent', () => {
useProductWorkspaceStore.setState((s) => {
s.context.activeProduct = { id: 'prod-1', name: 'P' }
})
useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]),
)
useProductWorkspaceStore.getState().applyRealtimeEvent({
entity: 'pbi',
op: 'I',
id: 'a',
product_id: 'prod-1',
title: 'echo',
})
expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel')
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
})
})