// 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();
});
});