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