stores/idea-store.ts (Zustand): - jobByIdea, ideaStatuses, openQuestionsByIdea - handleIdeaJobEvent: derives optimistic ideaStatus (queued/claimed/running → grilling/planning; failed → grill_failed/plan_failed; done = no-op since the server-side update_idea_*_md is source-of-truth) - handleIdeaQuestionEvent: list-based, removes on non-open - setIdeaStatus / setJobStatus / clearForIdea optimistic helpers - connectedWorkers NOT duplicated — UI reads useSoloStore(s.connectedWorkers) lib/realtime/use-notifications-realtime.ts: - Single SSE serves both bell-questions and idea-state. Adds dispatcher branches: idea-job payloads → idea-store; idea-question payloads (idea_id set) → idea-store; story-questions → existing notifications-store path. Tests: 7/7 idea-store cases (queued→grilling, failed→*_failed, done no-op, question-list management, clearForIdea isolation). Full suite: 546/546 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
145 lines
4.1 KiB
TypeScript
145 lines
4.1 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
|
|
import { useIdeaStore } from '@/stores/idea-store'
|
|
|
|
beforeEach(() => {
|
|
// Reset store between tests — Zustand persists state across tests otherwise.
|
|
useIdeaStore.setState({
|
|
jobByIdea: {},
|
|
ideaStatuses: {},
|
|
openQuestionsByIdea: {},
|
|
})
|
|
})
|
|
|
|
describe('useIdeaStore — handleIdeaJobEvent', () => {
|
|
it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => {
|
|
useIdeaStore.getState().handleIdeaJobEvent({
|
|
type: 'claude_job_enqueued',
|
|
job_id: 'job-1',
|
|
idea_id: 'idea-1',
|
|
user_id: 'u-1',
|
|
kind: 'IDEA_GRILL',
|
|
status: 'queued',
|
|
})
|
|
const s = useIdeaStore.getState()
|
|
expect(s.jobByIdea['idea-1']?.status).toBe('queued')
|
|
expect(s.ideaStatuses['idea-1']).toBe('grilling')
|
|
})
|
|
|
|
it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => {
|
|
useIdeaStore.getState().handleIdeaJobEvent({
|
|
type: 'claude_job_status',
|
|
job_id: 'job-1',
|
|
idea_id: 'idea-1',
|
|
user_id: 'u-1',
|
|
kind: 'IDEA_GRILL',
|
|
status: 'failed',
|
|
error: 'oops',
|
|
})
|
|
expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed')
|
|
expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops')
|
|
})
|
|
|
|
it('failed IDEA_MAKE_PLAN → plan_failed', () => {
|
|
useIdeaStore.getState().handleIdeaJobEvent({
|
|
type: 'claude_job_status',
|
|
job_id: 'job-2',
|
|
idea_id: 'idea-2',
|
|
user_id: 'u-1',
|
|
kind: 'IDEA_MAKE_PLAN',
|
|
status: 'failed',
|
|
})
|
|
expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed')
|
|
})
|
|
|
|
it('done does NOT auto-derive status (server is source-of-truth)', () => {
|
|
useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled')
|
|
useIdeaStore.getState().handleIdeaJobEvent({
|
|
type: 'claude_job_status',
|
|
job_id: 'job-3',
|
|
idea_id: 'idea-3',
|
|
user_id: 'u-1',
|
|
kind: 'IDEA_GRILL',
|
|
status: 'done',
|
|
})
|
|
expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled')
|
|
})
|
|
})
|
|
|
|
describe('useIdeaStore — handleIdeaQuestionEvent', () => {
|
|
it('non-open status removes question from list', () => {
|
|
useIdeaStore.getState().initQuestions('idea-1', [
|
|
{
|
|
id: 'q-1',
|
|
idea_id: 'idea-1',
|
|
question: 'Q',
|
|
options: null,
|
|
status: 'open',
|
|
created_at: '',
|
|
expires_at: '',
|
|
},
|
|
])
|
|
useIdeaStore.getState().handleIdeaQuestionEvent({
|
|
op: 'U',
|
|
entity: 'question',
|
|
id: 'q-1',
|
|
product_id: 'p-1',
|
|
story_id: null,
|
|
idea_id: 'idea-1',
|
|
status: 'answered',
|
|
})
|
|
expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([])
|
|
})
|
|
|
|
it('open status keeps existing list (no detail in payload)', () => {
|
|
const q = {
|
|
id: 'q-1',
|
|
idea_id: 'idea-1',
|
|
question: 'Q',
|
|
options: null,
|
|
status: 'open' as const,
|
|
created_at: '',
|
|
expires_at: '',
|
|
}
|
|
useIdeaStore.getState().initQuestions('idea-1', [q])
|
|
useIdeaStore.getState().handleIdeaQuestionEvent({
|
|
op: 'I',
|
|
entity: 'question',
|
|
id: 'q-2',
|
|
product_id: 'p-1',
|
|
story_id: null,
|
|
idea_id: 'idea-1',
|
|
status: 'open',
|
|
})
|
|
// List length blijft 1 (server-fetch leveert de detail)
|
|
expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('useIdeaStore — clearForIdea', () => {
|
|
it('removes job + status + questions for one idea, leaves others', () => {
|
|
const s = useIdeaStore.getState()
|
|
s.setJobStatus({
|
|
job_id: 'j-1',
|
|
idea_id: 'idea-1',
|
|
kind: 'IDEA_GRILL',
|
|
status: 'running',
|
|
})
|
|
s.setJobStatus({
|
|
job_id: 'j-2',
|
|
idea_id: 'idea-2',
|
|
kind: 'IDEA_GRILL',
|
|
status: 'running',
|
|
})
|
|
s.setIdeaStatus('idea-1', 'grilling')
|
|
s.setIdeaStatus('idea-2', 'grilling')
|
|
|
|
s.clearForIdea('idea-1')
|
|
|
|
const after = useIdeaStore.getState()
|
|
expect(after.jobByIdea['idea-1']).toBeUndefined()
|
|
expect(after.jobByIdea['idea-2']).toBeDefined()
|
|
expect(after.ideaStatuses['idea-1']).toBeUndefined()
|
|
expect(after.ideaStatuses['idea-2']).toBe('grilling')
|
|
})
|
|
})
|