From ac8f949a193a346ef08dfe533cda50fe19bfa1c8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sat, 21 Mar 2026 00:17:13 -0700 Subject: [PATCH] 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. --- web/src/stores/survey.test.ts | 141 +++++++++++++++++++++++++++++++++ web/src/stores/survey.ts | 145 ++++++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 web/src/stores/survey.test.ts create mode 100644 web/src/stores/survey.ts diff --git a/web/src/stores/survey.test.ts b/web/src/stores/survey.test.ts new file mode 100644 index 0000000..e7ce8cb --- /dev/null +++ b/web/src/stores/survey.test.ts @@ -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() + }) +}) diff --git a/web/src/stores/survey.ts b/web/src/stores/survey.ts new file mode 100644 index 0000000..63127e7 --- /dev/null +++ b/web/src/stores/survey.ts @@ -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(null) + const history = ref([]) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + const visionAvailable = ref(false) + const currentJobId = ref(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(`/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, + } +})