feat(survey): add survey Pinia store with tests
Setup-store pattern (setup function style) with fetchFor, analyze, saveResponse, and clear. analysis ref stores mode + rawInput so saveResponse can build the full POST body without re-passing them. 6/6 unit tests pass; full suite 15/15.
This commit is contained in:
parent
595035e02d
commit
80ed7a470a
2 changed files with 286 additions and 0 deletions
141
web/src/stores/survey.test.ts
Normal file
141
web/src/stores/survey.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useSurveyStore } from './survey'
|
||||
|
||||
vi.mock('../composables/useApi', () => ({
|
||||
useApiFetch: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
describe('useSurveyStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchFor loads history and vision availability in parallel', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: [], error: null }) // history
|
||||
.mockResolvedValueOnce({ data: { available: true }, error: null }) // vision
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.fetchFor(1)
|
||||
|
||||
expect(store.history).toEqual([])
|
||||
expect(store.visionAvailable).toBe(true)
|
||||
expect(store.currentJobId).toBe(1)
|
||||
expect(mockApiFetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('fetchFor clears state when called for a different job', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
// Job 1
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'old' }], error: null })
|
||||
.mockResolvedValueOnce({ data: { available: false }, error: null })
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.fetchFor(1)
|
||||
expect(store.history.length).toBe(1)
|
||||
|
||||
// Job 2 — state must be cleared before new data arrives
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||
|
||||
await store.fetchFor(2)
|
||||
expect(store.history).toEqual([])
|
||||
expect(store.currentJobId).toBe(2)
|
||||
})
|
||||
|
||||
it('analyze stores result including mode and rawInput', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
mockApiFetch.mockResolvedValueOnce({
|
||||
data: { output: '1. B — reason', source: 'text_paste' },
|
||||
error: null,
|
||||
})
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||
|
||||
expect(store.analysis).not.toBeNull()
|
||||
expect(store.analysis!.output).toBe('1. B — reason')
|
||||
expect(store.analysis!.source).toBe('text_paste')
|
||||
expect(store.analysis!.mode).toBe('quick')
|
||||
expect(store.analysis!.rawInput).toBe('Q1: test')
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('analyze sets error on failure', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
mockApiFetch.mockResolvedValueOnce({
|
||||
data: null,
|
||||
error: { kind: 'http', status: 500, detail: 'LLM unavailable' },
|
||||
})
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.analyze(1, { text: 'Q1: test', mode: 'quick' })
|
||||
|
||||
expect(store.analysis).toBeNull()
|
||||
expect(store.error).toBeTruthy()
|
||||
expect(store.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('saveResponse prepends to history and clears analysis', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
// Setup: fetchFor
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: [], error: null })
|
||||
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.fetchFor(1)
|
||||
|
||||
// Set analysis state manually (as if analyze() was called)
|
||||
store.analysis = {
|
||||
output: '1. B — reason',
|
||||
source: 'text_paste',
|
||||
mode: 'quick',
|
||||
rawInput: 'Q1: test',
|
||||
}
|
||||
|
||||
// Save
|
||||
mockApiFetch.mockResolvedValueOnce({
|
||||
data: { id: 42 },
|
||||
error: null,
|
||||
})
|
||||
|
||||
await store.saveResponse(1, { surveyName: 'Round 1', reportedScore: '85%' })
|
||||
|
||||
expect(store.history.length).toBe(1)
|
||||
expect(store.history[0].id).toBe(42)
|
||||
expect(store.history[0].llm_output).toBe('1. B — reason')
|
||||
expect(store.analysis).toBeNull()
|
||||
expect(store.saving).toBe(false)
|
||||
})
|
||||
|
||||
it('clear resets all state to initial values', async () => {
|
||||
const mockApiFetch = vi.mocked(useApiFetch)
|
||||
mockApiFetch
|
||||
.mockResolvedValueOnce({ data: [{ id: 1, llm_output: 'test' }], error: null })
|
||||
.mockResolvedValueOnce({ data: { available: true }, error: null })
|
||||
|
||||
const store = useSurveyStore()
|
||||
await store.fetchFor(1)
|
||||
|
||||
store.clear()
|
||||
|
||||
expect(store.history).toEqual([])
|
||||
expect(store.analysis).toBeNull()
|
||||
expect(store.visionAvailable).toBe(false)
|
||||
expect(store.loading).toBe(false)
|
||||
expect(store.saving).toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
expect(store.currentJobId).toBeNull()
|
||||
})
|
||||
})
|
||||
145
web/src/stores/survey.ts
Normal file
145
web/src/stores/survey.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useApiFetch } from '../composables/useApi'
|
||||
|
||||
export interface SurveyAnalysis {
|
||||
output: string
|
||||
source: 'text_paste' | 'screenshot'
|
||||
mode: 'quick' | 'detailed'
|
||||
rawInput: string | null
|
||||
}
|
||||
|
||||
export interface SurveyResponse {
|
||||
id: number
|
||||
survey_name: string | null
|
||||
mode: 'quick' | 'detailed'
|
||||
source: string
|
||||
raw_input: string | null
|
||||
image_path: string | null
|
||||
llm_output: string
|
||||
reported_score: string | null
|
||||
received_at: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export const useSurveyStore = defineStore('survey', () => {
|
||||
const analysis = ref<SurveyAnalysis | null>(null)
|
||||
const history = ref<SurveyResponse[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const visionAvailable = ref(false)
|
||||
const currentJobId = ref<number | null>(null)
|
||||
|
||||
async function fetchFor(jobId: number) {
|
||||
if (jobId !== currentJobId.value) {
|
||||
analysis.value = null
|
||||
history.value = []
|
||||
error.value = null
|
||||
visionAvailable.value = false
|
||||
currentJobId.value = jobId
|
||||
}
|
||||
|
||||
const [historyResult, visionResult] = await Promise.all([
|
||||
useApiFetch<SurveyResponse[]>(`/api/jobs/${jobId}/survey/responses`),
|
||||
useApiFetch<{ available: boolean }>('/api/vision/health'),
|
||||
])
|
||||
|
||||
if (historyResult.error) {
|
||||
error.value = 'Could not load survey history.'
|
||||
} else {
|
||||
history.value = historyResult.data ?? []
|
||||
}
|
||||
|
||||
visionAvailable.value = visionResult.data?.available ?? false
|
||||
}
|
||||
|
||||
async function analyze(
|
||||
jobId: number,
|
||||
payload: { text?: string; image_b64?: string; mode: 'quick' | 'detailed' }
|
||||
) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { data, error: fetchError } = await useApiFetch<{ output: string; source: string }>(
|
||||
`/api/jobs/${jobId}/survey/analyze`,
|
||||
{ method: 'POST', body: JSON.stringify(payload) }
|
||||
)
|
||||
loading.value = false
|
||||
if (fetchError || !data) {
|
||||
error.value = 'Analysis failed. Please try again.'
|
||||
return
|
||||
}
|
||||
analysis.value = {
|
||||
output: data.output,
|
||||
source: data.source as 'text_paste' | 'screenshot',
|
||||
mode: payload.mode,
|
||||
rawInput: payload.text ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async function saveResponse(
|
||||
jobId: number,
|
||||
args: { surveyName: string; reportedScore: string; image_b64?: string }
|
||||
) {
|
||||
if (!analysis.value) return
|
||||
saving.value = true
|
||||
error.value = null
|
||||
const body = {
|
||||
survey_name: args.surveyName || undefined,
|
||||
mode: analysis.value.mode,
|
||||
source: analysis.value.source,
|
||||
raw_input: analysis.value.rawInput,
|
||||
image_b64: args.image_b64,
|
||||
llm_output: analysis.value.output,
|
||||
reported_score: args.reportedScore || undefined,
|
||||
}
|
||||
const { data, error: fetchError } = await useApiFetch<{ id: number }>(
|
||||
`/api/jobs/${jobId}/survey/responses`,
|
||||
{ method: 'POST', body: JSON.stringify(body) }
|
||||
)
|
||||
saving.value = false
|
||||
if (fetchError || !data) {
|
||||
error.value = 'Save failed. Your analysis is preserved — try again.'
|
||||
return
|
||||
}
|
||||
// Prepend the saved response to history
|
||||
const saved: SurveyResponse = {
|
||||
id: data.id,
|
||||
survey_name: args.surveyName || null,
|
||||
mode: analysis.value.mode,
|
||||
source: analysis.value.source,
|
||||
raw_input: analysis.value.rawInput,
|
||||
image_path: null,
|
||||
llm_output: analysis.value.output,
|
||||
reported_score: args.reportedScore || null,
|
||||
received_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
history.value = [saved, ...history.value]
|
||||
analysis.value = null
|
||||
}
|
||||
|
||||
function clear() {
|
||||
analysis.value = null
|
||||
history.value = []
|
||||
loading.value = false
|
||||
saving.value = false
|
||||
error.value = null
|
||||
visionAvailable.value = false
|
||||
currentJobId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
analysis,
|
||||
history,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
visionAvailable,
|
||||
currentJobId,
|
||||
fetchFor,
|
||||
analyze,
|
||||
saveResponse,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue