// src/features/flyer/AnalysisPanel.test.tsx import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { AnalysisPanel } from './AnalysisPanel'; import { useFlyerItems } from '../../hooks/useFlyerItems'; import type { Flyer, FlyerItem, Store, MasterGroceryItem } from '../../types'; import { useUserData } from '../../hooks/useUserData'; import { useAiAnalysis } from '../../hooks/useAiAnalysis'; import { createMockFlyer, createMockFlyerItem, createMockMasterGroceryItem, createMockStore, } from '../../tests/utils/mockFactories'; // Mock the logger vi.mock('../../services/logger.client', () => ({ logger: { info: vi.fn(), error: vi.fn() }, })); // Mock the AiAnalysisService to prevent real instantiation vi.mock('../../services/aiAnalysisService', () => { console.log('DEBUG: Setting up AiAnalysisService mock'); return { AiAnalysisService: class { constructor() { console.log('DEBUG: AiAnalysisService constructor mocked call'); } }, }; }); // Mock the data hooks vi.mock('../../hooks/useFlyerItems'); const mockedUseFlyerItems = useFlyerItems as Mock; vi.mock('../../hooks/useUserData'); const mockedUseUserData = useUserData as Mock; vi.mock('../../hooks/useAiAnalysis'); const mockedUseAiAnalysis = useAiAnalysis as Mock; // Mock the new icon vi.mock('../../components/icons/ScaleIcon', () => ({ ScaleIcon: () =>
, })); // Mock functions to be returned by the useAiAnalysis hook const mockRunAnalysis = vi.fn(); const mockGenerateImage = vi.fn(); const mockFlyerItems: FlyerItem[] = [ createMockFlyerItem({ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, }), ]; const mockWatchedItems: MasterGroceryItem[] = [ createMockMasterGroceryItem({ master_grocery_item_id: 101, name: 'Bananas' }), createMockMasterGroceryItem({ master_grocery_item_id: 102, name: 'Milk' }), ]; const mockStore: Store = createMockStore({ store_id: 1, name: 'SuperMart' }); const mockFlyer: Flyer = createMockFlyer({ flyer_id: 1, store: mockStore, }); describe('AnalysisPanel', () => { beforeEach(() => { console.log('DEBUG: beforeEach setup start'); vi.clearAllMocks(); mockedUseFlyerItems.mockReturnValue({ flyerItems: mockFlyerItems, isLoading: false, error: null, }); mockedUseUserData.mockReturnValue({ watchedItems: mockWatchedItems, isLoading: false, error: null, }); mockedUseAiAnalysis.mockReturnValue({ results: {}, sources: {}, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); console.log('DEBUG: beforeEach setup complete'); }); afterEach(() => { console.log('DEBUG: afterEach - cleaning up'); vi.clearAllMocks(); }); // Increased timeout to 20s to prevent flaky timeouts in slow environments it('should render tabs and an initial "Generate" button', { timeout: 20000 }, () => { console.log('DEBUG: Starting render for initial state test'); const startTime = Date.now(); render(); console.log(`DEBUG: Render finished in ${Date.now() - startTime}ms`); // Use the 'tab' role to specifically target the tab buttons console.log('DEBUG: Asserting tab existence'); expect(screen.getByRole('tab', { name: /quick insights/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /deep dive/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /web search/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /plan trip/i })).toBeInTheDocument(); expect(screen.getByRole('tab', { name: /compare prices/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /generate quick insights/i })).toBeInTheDocument(); console.log('DEBUG: Initial state test complete'); }); it('should switch tabs and update the generate button text', () => { render(); fireEvent.click(screen.getByRole('tab', { name: /deep dive/i })); expect(screen.getByRole('button', { name: /generate deep dive/i })).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /compare prices/i })); expect(screen.getByRole('button', { name: /generate compare prices/i })).toBeInTheDocument(); }); it('should call getQuickInsights and display the result', async () => { const { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i })); expect(mockRunAnalysis).toHaveBeenCalledWith('QUICK_INSIGHTS'); // Simulate the hook updating with results mockedUseAiAnalysis.mockReturnValue({ results: { QUICK_INSIGHTS: 'These are quick insights.' }, sources: {}, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByText('These are quick insights.')).toBeInTheDocument(); }); it('should display results with sources, and handle sources without a URI', () => { const { rerender } = render(); fireEvent.click(screen.getByRole('tab', { name: /web search/i })); mockedUseAiAnalysis.mockReturnValue({ results: { WEB_SEARCH: 'Search results text.' }, sources: { WEB_SEARCH: [ { title: 'Valid Source', uri: 'https://example.com/source1' }, { title: 'Source without URI', uri: null }, { title: 'Another Valid Source', uri: 'https://example.com/source2' }, ], }, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByText('Search results text.')).toBeInTheDocument(); expect(screen.getByText('Sources:')).toBeInTheDocument(); const source1 = screen.getByText('Valid Source'); expect(source1).toBeInTheDocument(); expect(source1.closest('a')).toHaveAttribute('href', 'https://example.com/source1'); expect(screen.queryByText('Source without URI')).not.toBeInTheDocument(); expect(screen.getByText('Another Valid Source')).toBeInTheDocument(); }); it('should show a loading spinner when fetching initial items', () => { mockedUseFlyerItems.mockReturnValue({ flyerItems: [], isLoading: true, error: null, }); render(); expect(screen.getByRole('status')).toBeInTheDocument(); expect(screen.getByText('Loading data...')).toBeInTheDocument(); }); it('should show an error if fetching items fails', () => { mockedUseFlyerItems.mockReturnValue({ flyerItems: [], isLoading: false, error: new Error('Network Error'), }); render(); expect(screen.getByText('Could not load flyer items: Network Error')).toBeInTheDocument(); }); it('should show a loading spinner during analysis', () => { mockedUseAiAnalysis.mockReturnValue({ ...mockedUseAiAnalysis(), loadingAnalysis: 'QUICK_INSIGHTS', }); render(); expect(screen.getByRole('status')).toBeInTheDocument(); // The simple spinner doesn't have text next to it expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); }); it('should display an error message if analysis fails', async () => { const { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i })); // Simulate the hook returning an error mockedUseAiAnalysis.mockReturnValue({ results: {}, sources: {}, loadingAnalysis: null, error: 'AI API is down', runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByText('AI API is down')).toBeInTheDocument(); }); it('should handle the image generation flow', async () => { const { rerender } = render(); fireEvent.click(screen.getByRole('tab', { name: /deep dive/i })); // 1. Show result and "Generate Image" button mockedUseAiAnalysis.mockReturnValue({ results: { DEEP_DIVE: 'This is a meal plan.' }, sources: {}, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); const generateImageButton = screen.getByRole('button', { name: /generate an image/i }); expect(generateImageButton).toBeInTheDocument(); // 2. Click button, show loading state fireEvent.click(generateImageButton); expect(mockGenerateImage).toHaveBeenCalled(); mockedUseAiAnalysis.mockReturnValue({ results: { DEEP_DIVE: 'This is a meal plan.' }, sources: {}, loadingAnalysis: 'GENERATE_IMAGE', error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByRole('button', { name: /generating/i })).toBeDisabled(); expect(screen.getByText('Generating...')).toBeInTheDocument(); // 3. Show the generated image mockedUseAiAnalysis.mockReturnValue({ results: { DEEP_DIVE: 'This is a meal plan.' }, sources: {}, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: 'https://example.com/meal.jpg', generateImage: mockGenerateImage, }); rerender(); const image = screen.getByAltText('AI generated meal plan'); expect(image).toBeInTheDocument(); expect(image).toHaveAttribute('src', 'https://example.com/meal.jpg'); }); it('should not show sources for non-search analysis types', () => { mockedUseAiAnalysis.mockReturnValue({ results: { QUICK_INSIGHTS: 'Some insights.' }, sources: { QUICK_INSIGHTS: [{ title: 'A source', uri: 'http://a.com' }] }, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); render(); expect(screen.getByText('Some insights.')).toBeInTheDocument(); expect(screen.queryByText('Sources:')).not.toBeInTheDocument(); }); it('should display sources for Plan Trip analysis type', () => { const { rerender } = render(); fireEvent.click(screen.getByRole('tab', { name: /plan trip/i })); mockedUseAiAnalysis.mockReturnValue({ results: { PLAN_TRIP: 'Here is your trip plan.' }, sources: { PLAN_TRIP: [{ title: 'Store Location', uri: 'https://maps.example.com/store1' }], }, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByText('Here is your trip plan.')).toBeInTheDocument(); expect(screen.getByText('Sources:')).toBeInTheDocument(); expect(screen.getByText('Store Location')).toBeInTheDocument(); }); it('should display sources for Compare Prices analysis type', () => { const { rerender } = render(); fireEvent.click(screen.getByRole('tab', { name: /compare prices/i })); mockedUseAiAnalysis.mockReturnValue({ results: { COMPARE_PRICES: 'Price comparison results.' }, sources: { COMPARE_PRICES: [{ title: 'Price Source', uri: 'https://prices.example.com/compare' }], }, loadingAnalysis: null, error: null, runAnalysis: mockRunAnalysis, generatedImageUrl: null, generateImage: mockGenerateImage, }); rerender(); expect(screen.getByText('Price comparison results.')).toBeInTheDocument(); expect(screen.getByText('Sources:')).toBeInTheDocument(); expect(screen.getByText('Price Source')).toBeInTheDocument(); }); it('should show a loading spinner when loading watched items', () => { mockedUseUserData.mockReturnValue({ watchedItems: [], isLoading: true, error: null, }); render(); expect(screen.getByRole('status')).toBeInTheDocument(); expect(screen.getByText('Loading data...')).toBeInTheDocument(); }); });