All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
// 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: () => <div data-testid="scale-icon" />,
|
|
}));
|
|
|
|
// 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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
expect(screen.getByText('These are quick insights.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display results with sources, and handle sources without a URI', () => {
|
|
const { rerender } = render(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
expect(screen.getByText('AI API is down')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle the image generation flow', async () => {
|
|
const { rerender } = render(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
expect(screen.getByText('Some insights.')).toBeInTheDocument();
|
|
expect(screen.queryByText('Sources:')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should display sources for Plan Trip analysis type', () => {
|
|
const { rerender } = render(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
|
|
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(<AnalysisPanel selectedFlyer={mockFlyer} />);
|
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
expect(screen.getByText('Loading data...')).toBeInTheDocument();
|
|
});
|
|
});
|