From 6c17f202eda128a239811143e73449aa6e728d91 Mon Sep 17 00:00:00 2001 From: Torben Sorensen Date: Wed, 17 Dec 2025 17:00:11 -0800 Subject: [PATCH] refactor AI Analysis system --- src/features/flyer/AnalysisPanel.tsx | 24 +- src/hooks/useAiAnalysis.test.ts | 375 +++++++-------------------- src/hooks/useAiAnalysis.ts | 309 ++++++++++------------ src/services/aiAnalysisService.ts | 97 +++++++ src/types.ts | 52 ++++ 5 files changed, 403 insertions(+), 454 deletions(-) create mode 100644 src/services/aiAnalysisService.ts diff --git a/src/features/flyer/AnalysisPanel.tsx b/src/features/flyer/AnalysisPanel.tsx index 8fd7a810..8abf0045 100644 --- a/src/features/flyer/AnalysisPanel.tsx +++ b/src/features/flyer/AnalysisPanel.tsx @@ -1,5 +1,5 @@ // src/features/flyer/AnalysisPanel.tsx -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { AnalysisType, Flyer } from '../../types'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { LightbulbIcon } from '../../components/icons/LightbulbIcon'; @@ -10,6 +10,7 @@ import { PhotoIcon as ImageIcon } from '../../components/icons/PhotoIcon'; import { ScaleIcon } from '../../components/icons/ScaleIcon'; import { useFlyerItems } from '../../hooks/useFlyerItems'; import { useUserData } from '../../hooks/useUserData'; +import { AiAnalysisService } from '../../services/aiAnalysisService'; import { useAiAnalysis } from '../../hooks/useAiAnalysis'; interface AnalysisPanelProps { @@ -51,17 +52,21 @@ export const AnalysisPanel: React.FC = ({ selectedFlyer }) = const { flyerItems, isLoading: isLoadingItems, error: itemsError } = useFlyerItems(selectedFlyer); const { watchedItems, isLoading: isLoadingWatchedItems } = useUserData(); const [activeTab, setActiveTab] = useState(AnalysisType.QUICK_INSIGHTS); - + + // Instantiate the service. useMemo ensures it's a stable instance. + const aiAnalysisService = useMemo(() => new AiAnalysisService(), []); + const { + // The state is now a single object. results, sources, - loadingStates, + loadingAnalysis, error, - runAnalysis, generatedImageUrl, - isGeneratingImage, + // Actions are stable functions. + runAnalysis, generateImage, - } = useAiAnalysis({ flyerItems, selectedFlyer, watchedItems }); + } = useAiAnalysis({ flyerItems, selectedFlyer, watchedItems, service: aiAnalysisService }); const renderContent = () => { // Show loading state from item or watched items fetching first @@ -72,7 +77,8 @@ export const AnalysisPanel: React.FC = ({ selectedFlyer }) = ); } - if (loadingStates[activeTab]) { // Then show loading state from analysis + // The loading state is now simpler. + if (loadingAnalysis && loadingAnalysis !== AnalysisType.GENERATE_IMAGE) { // Add role="status" for accessibility, which fixes the test query. return (
@@ -117,10 +123,10 @@ export const AnalysisPanel: React.FC = ({ selectedFlyer }) = ) : ( )}
diff --git a/src/hooks/useAiAnalysis.test.ts b/src/hooks/useAiAnalysis.test.ts index f1b49001..5dae931b 100644 --- a/src/hooks/useAiAnalysis.test.ts +++ b/src/hooks/useAiAnalysis.test.ts @@ -1,17 +1,18 @@ // src/hooks/useAiAnalysis.test.ts -import React, { ReactNode } from 'react'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import React from 'react'; +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest'; import { useAiAnalysis } from './useAiAnalysis'; -import { useApi } from './useApi'; import { AnalysisType } from '../types'; -import type { Flyer, FlyerItem, MasterGroceryItem } from '../types'; // Removed ApiProvider import -import { ApiProvider } from '../providers/ApiProvider'; // Updated import path for ApiProvider +import type { Flyer, FlyerItem, MasterGroceryItem } from '../types'; import { logger } from '../services/logger.client'; -import * as aiApiClient from '../services/aiApiClient'; +import { AiAnalysisService } from '../services/aiAnalysisService'; // 1. Mock dependencies -vi.mock('./useApi'); +// We now mock our new service layer, which is the direct dependency of the hook. +vi.mock('../services/aiAnalysisService'); +// We also no longer need ApiProvider since the hook is decoupled from useApi. +vi.mock('../services/aiApiClient'); // Keep this to avoid issues with transitive dependencies if any vi.mock('../services/logger.client', () => ({ // We use a real object here to allow spying on individual methods @@ -23,365 +24,187 @@ vi.mock('../services/logger.client', () => ({ }, })); -const mockedUseApi = vi.mocked(useApi); - -// Mock the AI API client functions that are passed to useApi -vi.mock('../services/aiApiClient', () => ({ - getQuickInsights: vi.fn(), - getDeepDiveAnalysis: vi.fn(), - searchWeb: vi.fn(), - planTripWithMaps: vi.fn(), - compareWatchedItemPrices: vi.fn(), - generateImageFromText: vi.fn(), -})); - -// Create a wrapper component that includes the required provider -const wrapper = ({ children }: { children: ReactNode }) => React.createElement(ApiProvider, null, children); - -// --- Type definitions for our mocks to satisfy TypeScript --- - -// This matches the return type of the useApi hook -interface MockApiReturn { - execute: Mock<(...args: any[]) => any>; - data: TData | null; - loading: boolean; - error: Error | null; - isRefetching: boolean; - reset: Mock<() => void>; -} - -// This is a simplified version of the internal GroundedResponse type -interface MockGroundedResponse { - text: string; - sources: any[]; -} - -// --- Mocks for each useApi instance --- -const mockGetQuickInsights: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; -const mockGetDeepDive: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; -const mockSearchWeb: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; -const mockPlanTrip: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; -const mockComparePrices: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; -const mockGenerateImage: MockApiReturn = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }; - // 2. Mock data const mockFlyerItems: FlyerItem[] = [{ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }]; const mockWatchedItems: MasterGroceryItem[] = [{ master_grocery_item_id: 101, name: 'Bananas', created_at: '' }]; const mockSelectedFlyer: Flyer = { flyer_id: 1, store: { store_id: 1, name: 'SuperMart', created_at: '' } } as Flyer; describe('useAiAnalysis Hook', () => { + // We will create a new mock service instance for each test. + let mockService: Mocked; + + // The parameters passed to the hook now include the mock service. const defaultParams = { flyerItems: mockFlyerItems, selectedFlyer: mockSelectedFlyer, watchedItems: mockWatchedItems, + // This will be replaced with a fresh mock in beforeEach + service: {} as Mocked, }; beforeEach(() => { console.log('\n\n\n--- TEST CASE START ---'); - // **CRITICAL:** Reset the state of the mock objects before each test. - // We reset the properties, not the objects themselves. - Object.assign(mockGetQuickInsights, { execute: vi.fn(), data: null, loading: false, error: null }); - Object.assign(mockGetDeepDive, { execute: vi.fn(), data: null, loading: false, error: null }); - Object.assign(mockSearchWeb, { execute: vi.fn(), data: null, loading: false, error: null }); - Object.assign(mockPlanTrip, { execute: vi.fn(), data: null, loading: false, error: null }); - Object.assign(mockComparePrices, { execute: vi.fn(), data: null, loading: false, error: null }); - Object.assign(mockGenerateImage, { execute: vi.fn(), data: null, loading: false, error: null }); - vi.clearAllMocks(); - // **THE DEFINITIVE FIX:** - // Use a single, intelligent mock implementation that returns the correct - // persistent mock object based on the API function passed to `useApi`. - // This is robust and does not depend on call order or re-renders. - mockedUseApi.mockImplementation((apiFn: Function) => { - console.log(`[mockedUseApi] Implementation called for API function: ${apiFn.name}`); - if (apiFn === aiApiClient.getQuickInsights) { - return mockGetQuickInsights; - } - if (apiFn === aiApiClient.getDeepDiveAnalysis) { - return mockGetDeepDive; - } - if (apiFn === aiApiClient.searchWeb) { - return mockSearchWeb; - } - if (apiFn === aiApiClient.planTripWithMaps) { - return mockPlanTrip; - } - if (apiFn === aiApiClient.compareWatchedItemPrices) { - return mockComparePrices; - } - if (apiFn === aiApiClient.generateImageFromText) { - return mockGenerateImage; - } - // Fallback for any other unexpected calls - console.error(`[mockedUseApi] FATAL: Unexpected API function passed: ${apiFn.name}`); - return { - execute: vi.fn().mockRejectedValue(new Error(`Mock not found for API function: ${apiFn.name}`)), - data: null, - loading: false, - error: null, - isRefetching: false, - reset: vi.fn(), - }; - }); + // Create a fresh mock of the service for each test to ensure isolation. + mockService = new AiAnalysisService() as Mocked; + // Mock all methods of the service. + mockService.getQuickInsights = vi.fn(); + mockService.getDeepDiveAnalysis = vi.fn(); + mockService.searchWeb = vi.fn(); + mockService.planTripWithMaps = vi.fn(); + mockService.compareWatchedItemPrices = vi.fn(); + mockService.generateImageFromText = vi.fn(); - // Mock Geolocation API - Object.defineProperty(navigator, 'geolocation', { - writable: true, - value: { - getCurrentPosition: vi.fn().mockImplementation((success) => - success({ coords: { latitude: 50, longitude: 50 } }) - ), - }, - }); + // Inject the fresh mock into the default parameters for this test run. + defaultParams.service = mockService; }); it('should initialize with correct default states', () => { console.log('TEST: should initialize with correct default states'); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); console.log('Asserting initial state...'); expect(result.current.results).toEqual({}); expect(result.current.sources).toEqual({}); - expect(result.current.loadingStates).toEqual({ - [AnalysisType.QUICK_INSIGHTS]: false, - [AnalysisType.DEEP_DIVE]: false, - [AnalysisType.WEB_SEARCH]: false, - [AnalysisType.PLAN_TRIP]: false, - [AnalysisType.COMPARE_PRICES]: false, - }); + expect(result.current.loadingAnalysis).toBeNull(); expect(result.current.error).toBeNull(); expect(result.current.generatedImageUrl).toBeNull(); - expect(result.current.isGeneratingImage).toBe(false); console.log('Initial state assertions passed.'); }); describe('runAnalysis', () => { - it('should call the correct execute function for QUICK_INSIGHTS', async () => { - console.log('TEST: should call execute for QUICK_INSIGHTS'); - mockGetQuickInsights.execute.mockResolvedValue('Quick insights text'); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + it('should call the correct service method for QUICK_INSIGHTS and update state on success', async () => { + console.log('TEST: should handle QUICK_INSIGHTS success'); + // Arrange: Mock the service method to return a successful result. + const mockResult = 'Quick insights text'; + mockService.getQuickInsights.mockResolvedValue(mockResult); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Act: Running analysis for QUICK_INSIGHTS...'); + // Act: Call the action exposed by the hook. await act(async () => { await result.current.runAnalysis(AnalysisType.QUICK_INSIGHTS); }); - console.log('Assert: Checking if getQuickInsights.execute was called correctly.'); - expect(mockGetQuickInsights.execute).toHaveBeenCalledWith(mockFlyerItems); + // Assert: Check that the service was called and the state was updated correctly. + expect(mockService.getQuickInsights).toHaveBeenCalledWith(mockFlyerItems); + expect(result.current.results[AnalysisType.QUICK_INSIGHTS]).toBe(mockResult); + expect(result.current.loadingAnalysis).toBeNull(); + expect(result.current.error).toBeNull(); }); - it('should update results when quickInsightsData changes', () => { - console.log('TEST: should update results when quickInsightsData changes'); - const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); - - console.log('Arrange: Simulating useApi returning new data for QUICK_INSIGHTS.'); - // **FIX:** Mutate the existing mock object. - mockGetQuickInsights.data = 'New insights'; - console.log('Act: Re-rendering hook to simulate data update.'); - rerender(); - - console.log('Assert: Checking if results state was updated.'); - expect(result.current.results[AnalysisType.QUICK_INSIGHTS]).toBe('New insights'); - }); - - it('should call the correct execute function for DEEP_DIVE', async () => { + it('should call the correct service method for DEEP_DIVE', async () => { console.log('TEST: should call execute for DEEP_DIVE'); - mockGetDeepDive.execute.mockResolvedValue('Deep dive text'); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + const mockResult = 'Deep dive text'; + mockService.getDeepDiveAnalysis.mockResolvedValue(mockResult); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Act: Running analysis for DEEP_DIVE...'); await act(async () => { await result.current.runAnalysis(AnalysisType.DEEP_DIVE); }); - console.log('Assert: Checking if getDeepDive.execute was called correctly.'); - expect(mockGetDeepDive.execute).toHaveBeenCalledWith(mockFlyerItems); + expect(mockService.getDeepDiveAnalysis).toHaveBeenCalledWith(mockFlyerItems); + expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe(mockResult); }); - it('should update results and sources when webSearchData changes', () => { - console.log('TEST: should update results and sources for WEB_SEARCH'); - const mockResponse = { text: 'Web search text', sources: [{ web: { uri: 'http://a.com', title: 'Source A' } }] }; - - const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + it('should handle grounded responses for WEB_SEARCH', async () => { + console.log('TEST: should handle grounded responses for WEB_SEARCH'); + const mockResult = { text: 'Web search text', sources: [{ uri: 'http://a.com', title: 'Source A' }] }; + mockService.searchWeb.mockResolvedValue(mockResult); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Arrange: Simulating useApi returning new data for WEB_SEARCH.'); - // **FIX:** Mutate the existing mock object. - mockSearchWeb.data = mockResponse; - console.log('Act: Re-rendering hook to simulate data update.'); - rerender(); - - console.log('Assert: Checking if results and sources state were updated for WEB_SEARCH.'); - expect(result.current.results[AnalysisType.WEB_SEARCH]).toBe('Web search text'); - expect(result.current.sources[AnalysisType.WEB_SEARCH]).toEqual([{ uri: 'http://a.com', title: 'Source A' }]); - }); - - it('should call the correct execute function for COMPARE_PRICES', async () => { - console.log('TEST: should call execute for COMPARE_PRICES'); - mockComparePrices.execute.mockResolvedValue({ text: 'Price comparison text', sources: [] }); // This was a duplicate, fixed. - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); - - console.log('Act: Running analysis for COMPARE_PRICES...'); await act(async () => { - await result.current.runAnalysis(AnalysisType.COMPARE_PRICES); + await result.current.runAnalysis(AnalysisType.WEB_SEARCH); }); - console.log('Assert: Checking if comparePrices.execute was called correctly.'); - expect(mockComparePrices.execute).toHaveBeenCalledWith(mockWatchedItems); + expect(mockService.searchWeb).toHaveBeenCalledWith(mockFlyerItems); + expect(result.current.results[AnalysisType.WEB_SEARCH]).toBe(mockResult.text); + expect(result.current.sources[AnalysisType.WEB_SEARCH]).toEqual(mockResult.sources); }); - it('should call the correct execute function for PLAN_TRIP with geolocation', async () => { - console.log('TEST: should call execute for PLAN_TRIP with geolocation'); - mockPlanTrip.execute.mockResolvedValue({ text: 'Trip plan text', sources: [{ uri: 'http://maps.com', title: 'Map' }] }); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + it('should handle PLAN_TRIP and its specific arguments', async () => { + console.log('TEST: should handle PLAN_TRIP'); + const mockResult = { text: 'Trip plan text', sources: [{ uri: 'http://maps.com', title: 'Map' }] }; + mockService.planTripWithMaps.mockResolvedValue(mockResult); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Act: Running analysis for PLAN_TRIP...'); - await act(async () => { - await result.current.runAnalysis(AnalysisType.PLAN_TRIP); - }); + await act(async () => { + await result.current.runAnalysis(AnalysisType.PLAN_TRIP); + }); - console.log('Assert: Checking if geolocation and planTrip.execute were called correctly.'); - expect(navigator.geolocation.getCurrentPosition).toHaveBeenCalled(); - expect(mockPlanTrip.execute).toHaveBeenCalledWith( - mockFlyerItems, - mockSelectedFlyer.store, - { latitude: 50, longitude: 50 } - ); - console.log('PLAN_TRIP assertions passed.'); + expect(mockService.planTripWithMaps).toHaveBeenCalledWith(mockFlyerItems, mockSelectedFlyer.store); + expect(result.current.results[AnalysisType.PLAN_TRIP]).toBe(mockResult.text); }); - it('should derive a generic error message if an API call fails', () => { - console.log('TEST: should derive a generic error message on API failure'); + it('should set the error state if a service call fails', async () => { + console.log('TEST: should set error state on failure'); const apiError = new Error('API is down'); - - // Simulate useApi returning an error - console.log('Arrange: Mutating mock to simulate a useApi error.'); - // **FIX:** Mutate the existing mock object. - mockGetQuickInsights.error = apiError; + mockService.getQuickInsights.mockRejectedValue(apiError); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); - - console.log('Assert: Checking if the error state is correctly populated.'); - expect(result.current.error).toBe('API is down'); - console.log('Error state assertion passed.'); - }); - - it('should log an error for geolocation permission denial', async () => { - console.log('TEST: should handle geolocation permission denial'); - const geoError = new GeolocationPositionError(); - Object.defineProperty(geoError, 'code', { value: GeolocationPositionError.PERMISSION_DENIED }); - console.log('Arrange: Mocking navigator.geolocation.getCurrentPosition to call the error callback.'); - vi.mocked(navigator.geolocation.getCurrentPosition).mockImplementation((success, error) => { - if (error) error(geoError); - }); - - console.log('Arrange: Mocking planTrip.execute to reject, simulating a failure caught by useApi.'); - // The execute function will reject, and useApi will set the error state - const rejectionError = new Error("Geolocation permission denied."); - mockPlanTrip.execute.mockRejectedValue(rejectionError); - - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); - - console.log('Act: Running analysis for PLAN_TRIP, which is expected to fail.'); await act(async () => { - await result.current.runAnalysis(AnalysisType.PLAN_TRIP); + await result.current.runAnalysis(AnalysisType.QUICK_INSIGHTS); }); - console.log('Assert: Checking if the internal error state reflects the geolocation failure.'); - // The test now verifies that the error from the failed execute call is propagated. - // The specific user-friendly message is now part of the component that consumes the hook. - expect(result.current.error).toBe(rejectionError.message); + expect(mockService.getQuickInsights).toHaveBeenCalled(); + expect(result.current.error).toBe('API is down'); + expect(result.current.loadingAnalysis).toBeNull(); }); }); describe('generateImage', () => { - it('should not run if there are no DEEP_DIVE results', async () => { + it('should not call the service if there is no DEEP_DIVE result', async () => { console.log('TEST: should not run generateImage if DEEP_DIVE results are missing'); - const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + // Initial state has no results, so this test requires no setup. + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Act: Calling generateImage while results are empty.'); await act(async () => { await result.current.generateImage(); }); - console.log('Assert: Checking that logger.warn was called and the API was not.'); - expect(logger.warn).toHaveBeenCalledWith("[generateImage EXEC] Aborting: required DEEP_DIVE text is missing. Value was: 'undefined'"); - expect(mockGenerateImage.execute).not.toHaveBeenCalled(); - console.log('Assertion passed for no-op generateImage call.'); + expect(logger.warn).toHaveBeenCalledWith('generateImage called but no meal plan text available.'); + expect(mockService.generateImageFromText).not.toHaveBeenCalled(); }); - it('should call the API and set the image URL on success', async () => { - console.log('TEST: should call the API and set the image URL on success'); - const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + it('should call the service and update state on successful image generation', async () => { + console.log('TEST: should generate image on success'); + const mockBase64 = 'base64string'; + mockService.getDeepDiveAnalysis.mockResolvedValue('A great meal plan'); + mockService.generateImageFromText.mockResolvedValue(mockBase64); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Step 1 (Arrange): Mutating mock to provide DEEP_DIVE data.'); - // **FIX:** Mutate the persistent mock object. - mockGetDeepDive.data = 'A great meal plan'; - - console.log('Step 1 (Act): Re-rendering the hook to apply new data.'); - rerender(); - - console.log("Step 2 (Sync): Waiting for the hook's state to update."); - await waitFor(() => { - console.log(`WAITFOR Check: Is DEEP_DIVE result '${result.current.results[AnalysisType.DEEP_DIVE]}' === 'A great meal plan'?`); - expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan'); + // First, run the deep dive to populate the required state. + await act(async () => { + await result.current.runAnalysis(AnalysisType.DEEP_DIVE); }); - console.log('Step 2 (Sync): State confirmed via waitFor.'); - console.log('Step 3 (Act): Calling `generateImage`.'); + // Now, generate the image. await act(async () => { await result.current.generateImage(); }); - console.log('Step 4 (Assert): Verifying the image generation API was called correctly.'); - expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan'); - - console.log('Step 5 (Arrange): Mutating mock to provide successful image generation result.'); - // **FIX:** Mutate the persistent mock object. - mockGenerateImage.data = 'base64string'; - - console.log('Step 5 (Act): Re-rendering the hook to apply new image data.'); - rerender(); - - console.log('Step 6 (Sync): Waiting for the generatedImageUrl to be computed from the new data.'); - await waitFor(() => { - console.log(`WAITFOR Check: Is generatedImageUrl '${result.current.generatedImageUrl}' not null?`); - expect(result.current.generatedImageUrl).toBe('data:image/png;base64,base64string'); - }); - console.log('Step 6 (Assert): Image URL assertion passed.'); + expect(mockService.generateImageFromText).toHaveBeenCalledWith('A great meal plan'); + expect(result.current.generatedImageUrl).toBe(`data:image/png;base64,${mockBase64}`); + expect(result.current.loadingAnalysis).toBeNull(); }); it('should set an error if image generation fails', async () => { - console.log('TEST: should set an error if image generation fails'); - const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper }); + console.log('TEST: should handle image generation failure'); + const apiError = new Error('Image model failed'); + mockService.getDeepDiveAnalysis.mockResolvedValue('A great meal plan'); + mockService.generateImageFromText.mockRejectedValue(apiError); + const { result } = renderHook(() => useAiAnalysis(defaultParams)); - console.log('Step 1 (Arrange): Mutating mock to provide DEEP_DIVE data.'); - mockGetDeepDive.data = 'A great meal plan'; - console.log('Step 1 (Act): Re-rendering the hook.'); - rerender(); - - console.log("Step 2 (Sync): Waiting for the hook's state to update."); - await waitFor(() => { - console.log(`WAITFOR Check: Is DEEP_DIVE result '${result.current.results[AnalysisType.DEEP_DIVE]}' === 'A great meal plan'?`); - expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan'); + await act(async () => { + await result.current.runAnalysis(AnalysisType.DEEP_DIVE); }); - console.log('Step 2 (Sync): State confirmed.'); - console.log('Step 3 (Act): Call `generateImage`.'); await act(async () => { await result.current.generateImage(); }); - console.log('Step 4 (Arrange): Mutating mock to simulate an image generation error.'); - const apiError = new Error('Image model failed'); - mockGenerateImage.error = apiError; - - console.log('Step 4 (Act): Re-rendering the hook to apply the new error state.'); - rerender(); - - console.log("Step 5 (Assert): The error from the useApi hook should now be the hook's primary error state."); expect(result.current.error).toBe('Image model failed'); - console.log('Step 5 (Assert): Error state assertion passed.'); + expect(result.current.loadingAnalysis).toBeNull(); }); }); }); \ No newline at end of file diff --git a/src/hooks/useAiAnalysis.ts b/src/hooks/useAiAnalysis.ts index 3fa2eac2..b5c7f721 100644 --- a/src/hooks/useAiAnalysis.ts +++ b/src/hooks/useAiAnalysis.ts @@ -1,186 +1,157 @@ -// src/hooks/useAiAnalysis.ts -import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { Flyer, FlyerItem, MasterGroceryItem, AnalysisType } from '../types'; -import type { GroundingChunk } from '@google/genai'; -import * as aiApiClient from '../services/aiApiClient'; +import { useReducer, useCallback, useMemo } from 'react'; +import { + Flyer, + FlyerItem, + MasterGroceryItem, + AnalysisType, + AiAnalysisState, + AiAnalysisAction +} from '../types'; +import type { AiAnalysisService } from '../services/aiAnalysisService'; import { logger } from '../services/logger.client'; -import { useApi } from './useApi'; -interface Source { - uri: string; - title: string; -} +/** + * The initial state for the AI analysis reducer. + */ +const initialState: AiAnalysisState = { + loadingAnalysis: null, + error: null, + results: {}, + sources: {}, + generatedImageUrl: null, +}; -interface GroundedResponse { - text: string; - sources: (GroundingChunk | Source)[]; +/** + * A reducer function to manage the complex state of the AI analysis panel. + * It handles loading, success, and error states for multiple types of analysis. + * @param state - The current state. + * @param action - The action to perform. + * @returns The new state. + */ +function aiAnalysisReducer(state: AiAnalysisState, action: AiAnalysisAction): AiAnalysisState { + // Safely log the payload only if it exists on the action. + const payload = 'payload' in action ? action.payload : {}; + logger.info(`[aiAnalysisReducer] Dispatched action: ${action.type}`, { payload }); + switch (action.type) { + case 'FETCH_START': + return { + ...state, + loadingAnalysis: action.payload.analysisType, + error: null, // Clear previous errors on a new request + }; + case 'FETCH_SUCCESS_TEXT': + return { + ...state, + loadingAnalysis: null, + results: { + ...state.results, + [action.payload.analysisType]: action.payload.data, + }, + }; + case 'FETCH_SUCCESS_GROUNDED': + return { + ...state, + loadingAnalysis: null, + results: { + ...state.results, + [action.payload.analysisType]: action.payload.data.text, + }, + sources: { + ...state.sources, + [action.payload.analysisType]: action.payload.data.sources, + }, + }; + case 'FETCH_SUCCESS_IMAGE': + return { + ...state, + loadingAnalysis: null, + generatedImageUrl: `data:image/png;base64,${action.payload.data}`, + }; + case 'FETCH_ERROR': + return { + ...state, + loadingAnalysis: null, + error: action.payload.error, + }; + case 'CLEAR_ERROR': + return { ...state, error: null }; + case 'RESET_STATE': + return initialState; + default: + return state; + } } interface UseAiAnalysisParams { - flyerItems: FlyerItem[]; - selectedFlyer: Flyer | null; - watchedItems: MasterGroceryItem[]; + flyerItems: FlyerItem[]; + selectedFlyer: Flyer | null; + watchedItems: MasterGroceryItem[]; + // The service is now injected, decoupling the hook from its implementation. + service: AiAnalysisService; } -export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems }: UseAiAnalysisParams) => { - // --- Add a render counter for debugging --- - const renderCount = useRef(0); - renderCount.current += 1; +export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems, service }: UseAiAnalysisParams) => { + const [state, dispatch] = useReducer(aiAnalysisReducer, initialState); - const [results, setResults] = useState<{ [key in AnalysisType]?: string }>({}); - const [sources, setSources] = useState<{ [key in AnalysisType]?: Source[] }>({}); - const [internalError, setInternalError] = useState(null); - - // This log helps trace the entire lifecycle of the hook. - // We log the current state of `results` to see how it changes with each render. - logger.info(`[useAiAnalysis] START RENDER #${renderCount.current}.`, { - results: JSON.stringify(results), - }); - - - // --- API Hooks for each analysis type --- - const { execute: getQuickInsights, data: quickInsightsData, loading: loadingQuickInsights, error: errorQuickInsights } = useApi(aiApiClient.getQuickInsights); - const { execute: getDeepDive, data: deepDiveData, loading: loadingDeepDive, error: errorDeepDive } = useApi(aiApiClient.getDeepDiveAnalysis); - const { execute: searchWeb, data: webSearchData, loading: loadingWebSearch, error: errorWebSearch } = useApi(aiApiClient.searchWeb); - const { execute: planTrip, data: tripPlanData, loading: loadingTripPlan, error: errorTripPlan } = useApi(aiApiClient.planTripWithMaps); - const { execute: comparePrices, data: priceComparisonData, loading: loadingComparePrices, error: errorComparePrices } = useApi(aiApiClient.compareWatchedItemPrices); - const { execute: generateImageApi, data: generatedImageData, loading: isGeneratingImage, error: errorGenerateImage } = useApi(aiApiClient.generateImageFromText); - - // --- Derived State (Loading and Error) --- - const loadingStates = useMemo(() => ({ - [AnalysisType.QUICK_INSIGHTS]: loadingQuickInsights, - [AnalysisType.DEEP_DIVE]: loadingDeepDive, - [AnalysisType.WEB_SEARCH]: loadingWebSearch, - [AnalysisType.PLAN_TRIP]: loadingTripPlan, - [AnalysisType.COMPARE_PRICES]: loadingComparePrices, - }), [loadingQuickInsights, loadingDeepDive, loadingWebSearch, loadingTripPlan, loadingComparePrices]); - - const error = useMemo(() => { - if (internalError) return internalError; - const firstError = errorQuickInsights || errorDeepDive || errorWebSearch || errorTripPlan || errorComparePrices || errorGenerateImage; - return firstError ? firstError.message : null; - }, [internalError, errorQuickInsights, errorDeepDive, errorWebSearch, errorTripPlan, errorComparePrices, errorGenerateImage]); - - // --- Effects to update state when API data changes --- - logger.info(`[useAiAnalysis RENDER #${renderCount.current}] PRE-EFFECT. Dependencies are:`, { deepDiveData: !!deepDiveData, generatedImageData: !!generatedImageData }); - useEffect(() => { - logger.info(`[useAiAnalysis EFFECT #${renderCount.current}] Firing.`); - if (quickInsightsData) { - logger.info(`[useAiAnalysis Effect] quickInsightsData detected. Updating results state.`); - setResults(prev => ({ ...prev, [AnalysisType.QUICK_INSIGHTS]: quickInsightsData })); + const runAnalysis = useCallback(async (analysisType: AnalysisType) => { + dispatch({ type: 'FETCH_START', payload: { analysisType } }); + try { + // Delegate the call to the injected service. + switch (analysisType) { + case AnalysisType.QUICK_INSIGHTS: { + const data = await service.getQuickInsights(flyerItems); + dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } }); + break; } - if (deepDiveData) { - logger.info(`[useAiAnalysis Effect] deepDiveData detected ('${deepDiveData}'). Updating results state.`); - setResults(prev => ({ ...prev, [AnalysisType.DEEP_DIVE]: deepDiveData })); + case AnalysisType.DEEP_DIVE: { + const data = await service.getDeepDiveAnalysis(flyerItems); + dispatch({ type: 'FETCH_SUCCESS_TEXT', payload: { analysisType, data } }); + break; } - if (webSearchData) { - logger.info(`[useAiAnalysis Effect] webSearchData detected. Updating results and sources.`); - setResults(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: webSearchData.text })); - const mappedSources = (webSearchData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source); - setSources(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: mappedSources })); + case AnalysisType.WEB_SEARCH: { + const data = await service.searchWeb(flyerItems); + dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } }); + break; } - if (tripPlanData) { - logger.info(`[useAiAnalysis Effect] tripPlanData detected. Updating results and sources.`); - setResults(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.text })); - setSources(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.sources as Source[] })); + case AnalysisType.PLAN_TRIP: { + if (!selectedFlyer?.store) throw new Error("Store information is not available for trip planning."); + const data = await service.planTripWithMaps(flyerItems, selectedFlyer.store); + dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } }); + break; } - if (priceComparisonData) { - logger.info(`[useAiAnalysis Effect] priceComparisonData detected. Updating results and sources.`); - setResults(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: priceComparisonData.text })); - const mappedSources = (priceComparisonData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source); - setSources(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: mappedSources })); + case AnalysisType.COMPARE_PRICES: { + const data = await service.compareWatchedItemPrices(watchedItems); + dispatch({ type: 'FETCH_SUCCESS_GROUNDED', payload: { analysisType, data } }); + break; } - logger.info(`[useAiAnalysis Effect] Finish: Data processing complete.`); - }, [quickInsightsData, deepDiveData, webSearchData, tripPlanData, priceComparisonData]); + } + } catch (e: any) { + logger.error(`runAnalysis failed for type ${analysisType}`, { error: e }); + const message = e.message || 'An unexpected error occurred.'; + dispatch({ type: 'FETCH_ERROR', payload: { error: message } }); + } + }, [service, flyerItems, watchedItems, selectedFlyer]); - const generatedImageUrl = useMemo(() => generatedImageData ? `data:image/png;base64,${generatedImageData}` : null, [generatedImageData]); + const generateImage = useCallback(async () => { + const mealPlanText = state.results[AnalysisType.DEEP_DIVE]; + if (!mealPlanText) { + logger.warn('generateImage called but no meal plan text available.'); + return; + } + dispatch({ type: 'FETCH_START', payload: { analysisType: AnalysisType.GENERATE_IMAGE } }); + try { + const data = await service.generateImageFromText(mealPlanText); + dispatch({ type: 'FETCH_SUCCESS_IMAGE', payload: { data } }); + } catch (e: any) { + logger.error('generateImage failed', { error: e }); + const message = e.message || 'An unexpected error occurred during image generation.'; + dispatch({ type: 'FETCH_ERROR', payload: { error: message } }); + } + }, [service, state.results]); - const runAnalysis = useCallback(async (type: AnalysisType) => { - const renderWhenCreated = renderCount.current; - logger.info(`[runAnalysis EXEC] Running callback created during Render #${renderWhenCreated}.`); - setInternalError(null); - try { - if (type === AnalysisType.QUICK_INSIGHTS) { - logger.info('[runAnalysis EXEC] Calling getQuickInsights...'); - await getQuickInsights(flyerItems); - } else if (type === AnalysisType.DEEP_DIVE) { - logger.info('[runAnalysis EXEC] Calling getDeepDive...'); - await getDeepDive(flyerItems); - } else if (type === AnalysisType.WEB_SEARCH) { - logger.info('[runAnalysis EXEC] Calling searchWeb...'); - await searchWeb(flyerItems); - } else if (type === AnalysisType.PLAN_TRIP) { - logger.info('[runAnalysis EXEC] Requesting user location for PLAN_TRIP...'); - const userLocation = await new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(pos => resolve(pos.coords), err => reject(err)); - }); - logger.info('[runAnalysis EXEC] Location received. Calling planTrip...'); - await planTrip(flyerItems, selectedFlyer?.store, userLocation); - } else if (type === AnalysisType.COMPARE_PRICES) { - logger.info('[runAnalysis EXEC] Calling comparePrices...'); - await comparePrices(watchedItems); - } - } catch (e) { - // The useApi hook now handles setting the error state. - // We can add specific logging here if needed. - logger.error(`runAnalysis caught an error for type ${type}`, { error: e }); - let message = 'An unexpected error occurred'; - - // Check for Geolocation error specifically or by code (1 = PERMISSION_DENIED) - if ( - (typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code === 1) || - (typeof GeolocationPositionError !== 'undefined' && e instanceof GeolocationPositionError && e.code === GeolocationPositionError.PERMISSION_DENIED) - ) { - message = "Geolocation permission denied."; - } else if (e instanceof Error) { - message = e.message; - } else if (typeof e === 'object' && e !== null && 'message' in e) { - message = String((e as { message: unknown }).message); - } else if (typeof e === 'string') { - message = e; - } - - setInternalError(message); } - }, [ - flyerItems, - selectedFlyer?.store, - watchedItems, - getQuickInsights, - getDeepDive, - searchWeb, - planTrip, - comparePrices, - renderCount, - ]); - - const generateImage = useCallback(async () => { - const renderWhenCreated = renderCount.current; - logger.info(`[generateImage EXEC] Running callback created during Render #${renderWhenCreated}.`); - logger.info(`[generateImage EXEC] This function sees results:`, JSON.stringify(results)); - const mealPlanText = results[AnalysisType.DEEP_DIVE]; - if (!mealPlanText) { - logger.warn(`[generateImage EXEC] Aborting: required DEEP_DIVE text is missing. Value was: '${mealPlanText}'`); - return; - } - // Clear any previous internal errors. The useApi hook will manage the loading/error state - // for this specific API call, and its state is already wired up to this hook's `error` value. - setInternalError(null); - logger.info(`[generateImage EXEC] Proceeding to call generateImageApi with text snippet: "${mealPlanText.substring(0, 50)}..."`); - // No try/catch is needed here because the useApi hook handles promise rejections - // and exposes the error through its `error` return value. - await generateImageApi(mealPlanText); - }, [results, generateImageApi, renderCount]); - - logger.info(`[useAiAnalysis RENDER #${renderCount.current}] Re-defining generateImage. Callback will close over results:`, JSON.stringify(results)); - - return { - results, - sources, - loadingStates, - error, - runAnalysis, - generatedImageUrl, - isGeneratingImage, - generateImage, - }; + return useMemo(() => ({ + ...state, + runAnalysis, + generateImage, + }), [state, runAnalysis, generateImage]); }; \ No newline at end of file diff --git a/src/services/aiAnalysisService.ts b/src/services/aiAnalysisService.ts new file mode 100644 index 00000000..17c3c63a --- /dev/null +++ b/src/services/aiAnalysisService.ts @@ -0,0 +1,97 @@ +// src/services/aiAnalysisService.ts +import { Flyer, FlyerItem, MasterGroceryItem, GroundedResponse, Source, AnalysisType } from '../types'; +import * as aiApiClient from './aiApiClient'; +import { logger } from './logger.client'; + +/** + * A service class to encapsulate all AI analysis API calls and related business logic. + * This decouples the React components and hooks from the data fetching implementation. + */ +export class AiAnalysisService { + /** + * Fetches quick insights for a given set of flyer items. + * @param items - The flyer items to analyze. + * @returns The string result from the API. + */ + async getQuickInsights(items: FlyerItem[]): Promise { + logger.info('[AiAnalysisService] getQuickInsights called.'); + return aiApiClient.getQuickInsights(items).then(res => res.json()); + } + + /** + * Fetches a deep dive analysis for a given set of flyer items. + * @param items - The flyer items to analyze. + * @returns The string result from the API. + */ + async getDeepDiveAnalysis(items: FlyerItem[]): Promise { + logger.info('[AiAnalysisService] getDeepDiveAnalysis called.'); + return aiApiClient.getDeepDiveAnalysis(items).then(res => res.json()); + } + + /** + * Performs a web search based on the flyer items. + * @param items - The flyer items to use as context for the search. + * @returns A grounded response with text and sources. + */ + async searchWeb(items: FlyerItem[]): Promise { + logger.info('[AiAnalysisService] searchWeb called.'); + // The API client returns a specific shape that we need to await the JSON from + const response: { text: string; sources: any[] } = await aiApiClient.searchWeb(items).then(res => res.json()); + // Normalize sources to a consistent format. + const mappedSources = (response.sources || []).map((s: any) => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source); + return { ...response, sources: mappedSources }; + } + + /** + * Plans a shopping trip using maps and the user's location. + * @param items - The flyer items for the trip. + * @param store - The store associated with the flyer. + * @returns A grounded response with the trip plan and sources. + */ + async planTripWithMaps(items: FlyerItem[], store: Flyer['store']): Promise { + logger.info('[AiAnalysisService] planTripWithMaps called.'); + // Encapsulate geolocation logic within the service. + const userLocation = await this.getCurrentLocation(); + return aiApiClient.planTripWithMaps(items, store, userLocation).then(res => res.json()); + } + + /** + * Compares prices for a user's watched items. + * @param watchedItems - The list of master grocery items to compare. + * @returns A grounded response with the price comparison. + */ + async compareWatchedItemPrices(watchedItems: MasterGroceryItem[]): Promise { + logger.info('[AiAnalysisService] compareWatchedItemPrices called.'); + const response: { text: string; sources: any[] } = await aiApiClient.compareWatchedItemPrices(watchedItems).then(res => res.json()); + // Normalize sources to a consistent format. + const mappedSources = (response.sources || []).map((s: any) => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source); + return { ...response, sources: mappedSources }; + } + + /** + * Generates an image based on a provided text prompt (e.g., a meal plan). + * @param prompt - The text to use for image generation. + * @returns A base64 encoded string of the generated image. + */ + async generateImageFromText(prompt: string): Promise { + logger.info('[AiAnalysisService] generateImageFromText called.'); + return aiApiClient.generateImageFromText(prompt).then(res => res.json()); + } + + /** + * A helper to promisify the Geolocation API. + * @returns A promise that resolves with the user's coordinates. + * @private + */ + private getCurrentLocation(): Promise { + return new Promise((resolve, reject) => { + if (!navigator.geolocation) { + return reject(new Error('Geolocation is not supported by your browser.')); + } + navigator.geolocation.getCurrentPosition( + (pos) => resolve(pos.coords), + (err) => reject(err) + ); + }); + } +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 69f41899..d49defb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -653,6 +653,58 @@ export enum AnalysisType { COMPARE_PRICES = 'COMPARE_PRICES', } +/** + * Represents a source for a grounded response, normalized for consistent use in the UI. + */ +export interface Source { + uri: string; + title: string; +} + +/** + * Represents a response that may include sources, such as from a web search or map plan. + */ +export interface GroundedResponse { + text: string; + sources: Source[]; +} + +/** + * Defines the shape of the state managed by the useAiAnalysis hook's reducer. + * This centralizes all state related to AI analysis into a single, predictable object. + */ +export interface AiAnalysisState { + // The type of analysis currently being performed, if any. + loadingAnalysis: AnalysisType | null; + // A general error message for any failed analysis. + error: string | null; + // Stores the text result for each analysis type. + results: { [key in AnalysisType]?: string }; + // Stores the sources for analyses that provide them. + sources: { [key in AnalysisType]?: Source[] }; + // Stores the URL of the last generated image. + generatedImageUrl: string | null; +} + +/** + * Defines the actions that can be dispatched to the AiAnalysisReducer. + * This uses a discriminated union for strict type checking. + */ +export type AiAnalysisAction = + // Dispatched when any analysis starts. + | { type: 'FETCH_START'; payload: { analysisType: AnalysisType } } + // Dispatched when an analysis that returns a simple string succeeds. + | { type: 'FETCH_SUCCESS_TEXT'; payload: { analysisType: AnalysisType; data: string } } + // Dispatched when an analysis that returns text and sources succeeds. + | { type: 'FETCH_SUCCESS_GROUNDED'; payload: { analysisType: AnalysisType; data: GroundedResponse } } + // Dispatched when the image generation succeeds. + | { type: 'FETCH_SUCCESS_IMAGE'; payload: { data: string } } + // Dispatched when any analysis fails. + | { type: 'FETCH_ERROR'; payload: { error: string } } + // Dispatched to clear errors or reset state if needed. + | { type: 'CLEAR_ERROR' } + // Dispatched to reset the state to its initial values. + | { type: 'RESET_STATE' }; export type StageStatus = 'pending' | 'in-progress' | 'completed' | 'error'; export interface ProcessingStage {