refactor: Update tests and components for improved type safety and mock handling
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h11m16s

This commit is contained in:
2025-12-14 01:38:21 -08:00
parent f891da687b
commit 571ca59e82
12 changed files with 221 additions and 111 deletions

View File

@@ -1,6 +1,6 @@
// src/App.test.tsx
import React from 'react';
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, within, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Outlet } from 'react-router-dom';
import App from './App';
@@ -102,17 +102,19 @@ describe('App Component', () => {
// Default auth state: loading or guest
// Mock the login function to simulate a successful login.
const mockLogin = vi.fn(async (user: User, token: string) => {
// Simulate fetching profile after login
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
const userProfile = await profileResponse.json();
mockUseAuth.mockReturnValue({
user: userProfile.user,
profile: userProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: mockLogin, // Self-reference the mock
logout: vi.fn(),
updateProfile: vi.fn(),
await act(async () => {
// Simulate fetching profile after login
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
const userProfile: UserProfile = await profileResponse.json();
mockUseAuth.mockReturnValue({
user: userProfile.user,
profile: userProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: mockLogin, // Self-reference the mock
logout: vi.fn(),
updateProfile: vi.fn(),
});
});
});
mockUseAuth.mockReturnValue({
@@ -536,7 +538,7 @@ describe('App Component', () => {
it('should set an error state if login fails inside handleLoginSuccess', async () => {
const mockLogin = vi.fn().mockRejectedValue(new Error('Login failed'));
mockUseAuth.mockReturnValue({ ...mockUseAuth(), login: mockLogin });
mockUseAuth.mockReturnValue({ ...mockUseAuth(), login: mockLogin, user: null, profile: null, authStatus: 'SIGNED_OUT', isLoading: false, logout: vi.fn(), updateProfile: vi.fn() });
renderApp();
fireEvent.click(screen.getByText('Open Profile'));

View File

@@ -5,9 +5,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MapView } from './MapView';
// Helper to dynamically mock the config
interface MockConfig {
default: {
google: {
mapsEmbedApiKey?: string;
};
[key: string]: any; // Allow other properties
};
}
const setupConfigMock = (apiKey: string | undefined) => {
vi.doMock('../config', async () => {
const actualConfig = await vi.importActual('../config') as { default: any };
const actualConfig = await vi.importActual<MockConfig>('../config');
return {
...actualConfig,
default: {

View File

@@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { PriceHistoryChart } from './PriceHistoryChart';
import { useUserData } from '../../hooks/useUserData';
import * as apiClient from '../../services/apiClient';
import type { MasterGroceryItem, ItemPriceHistory } from '../../types';
import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types';
// Mock the apiClient
vi.mock('../../services/apiClient');
@@ -39,11 +39,11 @@ const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 2, name: 'Almond Milk', created_at: '2024-01-01' },
];
const mockPriceHistory: ItemPriceHistory[] = [
{ item_price_history_id: 1, master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, data_points_count: 1 },
{ item_price_history_id: 2, master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99, data_points_count: 1 },
{ item_price_history_id: 3, master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350, data_points_count: 1 },
{ item_price_history_id: 4, master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349, data_points_count: 1 },
const mockPriceHistory: HistoricalPriceDataPoint[] = [
{ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110 },
{ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99 },
{ master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350 },
{ master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349 },
];
describe('PriceHistoryChart', () => {
@@ -52,12 +52,23 @@ describe('PriceHistoryChart', () => {
// Provide a default successful mock for useUserData
mockedUseUserData.mockReturnValue({
watchedItems: mockWatchedItems,
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
} as any);
error: null,
});
});
it('should render a placeholder when there are no watched items', () => {
mockedUseUserData.mockReturnValue({ watchedItems: [], isLoading: false } as any);
mockedUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
render(<PriceHistoryChart />);
expect(screen.getByText('Add items to your watchlist to see their price trends over time.')).toBeInTheDocument();
});

View File

@@ -4,12 +4,8 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
import * as apiClient from '../../services/apiClient';
import { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct
import { useUserData } from '../../hooks/useUserData';
import type { HistoricalPriceDataPoint } from '../../types';
interface HistoricalPriceDataPoint {
master_item_id: number;
avg_price_in_cents: number | null;
summary_date: string;
}
type HistoricalData = Record<string, { date: string; price: number }[]>;
type ChartData = { date: string; [itemName: string]: number | string };

View File

@@ -4,7 +4,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock, type Mocked } from 'vitest';
import { AnalysisPanel } from './AnalysisPanel';
import { useFlyerItems } from '../../hooks/useFlyerItems';
import type { FlyerItem, Store, MasterGroceryItem } from '../../types';
import type { Flyer, FlyerItem, Store, MasterGroceryItem } from '../../types';
import { useUserData } from '../../hooks/useUserData';
import { useAiAnalysis } from '../../hooks/useAiAnalysis';
@@ -36,6 +36,14 @@ const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 102, name: 'Milk', created_at: '' },
];
const mockStore: Store = { store_id: 1, name: 'SuperMart', created_at: '' };
const mockFlyer: Flyer = {
flyer_id: 1,
created_at: '2024-01-01',
file_name: 'flyer.pdf',
image_url: 'http://example.com/flyer.jpg',
item_count: 1,
store: mockStore,
};
describe('AnalysisPanel', () => {
beforeEach(() => {
@@ -86,7 +94,7 @@ describe('AnalysisPanel', () => {
});
it('should render tabs and an initial "Generate" button', () => {
render(<AnalysisPanel selectedFlyer={{ store: mockStore } as any} />);
render(<AnalysisPanel selectedFlyer={mockFlyer} />);
// Use the 'tab' role to specifically target the tab buttons
expect(screen.getByRole('tab', { name: /quick insights/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /deep dive/i })).toBeInTheDocument();
@@ -97,7 +105,7 @@ describe('AnalysisPanel', () => {
});
it('should switch tabs and update the generate button text', () => {
render(<AnalysisPanel selectedFlyer={{ store: mockStore } as any} />);
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 }));
@@ -109,7 +117,7 @@ describe('AnalysisPanel', () => {
...mockedUseAiAnalysis(),
results: { QUICK_INSIGHTS: 'These are quick insights.' },
});
render(<AnalysisPanel selectedFlyer={{ store: mockStore } as any} />);
render(<AnalysisPanel selectedFlyer={mockFlyer} />);
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
@@ -152,7 +160,7 @@ describe('AnalysisPanel', () => {
...mockedUseAiAnalysis(),
error: 'AI API is down',
});
render(<AnalysisPanel selectedFlyer={{ store: mockStore } as any} />);
render(<AnalysisPanel selectedFlyer={mockFlyer} />);
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
// The component will re-render with the error from the hook
@@ -174,7 +182,7 @@ describe('AnalysisPanel', () => {
error(geolocationError);
}
);
render(<AnalysisPanel selectedFlyer={{ store: mockStore } as any} />);
render(<AnalysisPanel selectedFlyer={mockFlyer} />);
fireEvent.click(screen.getByRole('tab', { name: /plan trip/i }));
fireEvent.click(screen.getByRole('button', { name: /generate plan trip/i }));

View File

@@ -2,7 +2,7 @@
import React from 'react';
import { render, screen, fireEvent, within } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtractedDataTable } from './ExtractedDataTable';
import { ExtractedDataTable, ExtractedDataTableProps } from './ExtractedDataTable';
import type { FlyerItem, MasterGroceryItem, ShoppingList, User } from '../../types';
import { useAuth } from '../../hooks/useAuth';
import { useUserData } from '../../hooks/useUserData';
@@ -42,31 +42,65 @@ const mockShoppingLists: ShoppingList[] = [
const mockAddWatchedItem = vi.fn();
const mockAddItemToList = vi.fn();
const defaultProps = {
const defaultProps: ExtractedDataTableProps = {
items: mockFlyerItems,
unitSystem: 'imperial' as const,
// The following props are now sourced from the mocked hooks below,
// so they are not needed here and have been removed from the component's signature.
// totalActiveItems: mockFlyerItems.length,
// watchedItems: [],
// masterItems: mockMasterItems,
// user: mockUser,
// onAddItem: mockAddWatchedItem,
// shoppingLists: mockShoppingLists,
// activeListId: 1,
// onAddItemToList: mockAddItemToList,
};
describe('ExtractedDataTable', () => {
beforeEach(() => {
vi.clearAllMocks();
// Provide default mocks for all hooks
vi.mocked(useAuth).mockReturnValue({ user: mockUser } as any);
vi.mocked(useAuth).mockReturnValue({
user: mockUser,
profile: null,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
vi.mocked(useUserData).mockReturnValue({
watchedItems: [],
shoppingLists: mockShoppingLists,
} as any);
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
vi.mocked(useMasterItems).mockReturnValue({
masterItems: mockMasterItems,
} as any);
isLoading: false,
error: null,
});
vi.mocked(useWatchedItems).mockReturnValue({
addWatchedItem: mockAddWatchedItem,
} as any);
watchedItems: [],
removeWatchedItem: vi.fn(),
error: null,
});
vi.mocked(useShoppingLists).mockReturnValue({
activeListId: 1,
addItemToList: mockAddItemToList,
shoppingLists: mockShoppingLists, // Add this to satisfy the component's dependency
} as any);
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false,
error: null,
});
});
it('should render an empty state message if no items are provided', () => {
@@ -82,7 +116,15 @@ describe('ExtractedDataTable', () => {
});
it('should not show watch/add to list buttons for anonymous users', () => {
vi.mocked(useAuth).mockReturnValue({ user: null } as any);
vi.mocked(useAuth).mockReturnValue({
user: null,
profile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
render(<ExtractedDataTable {...defaultProps} />);
expect(screen.queryByRole('button', { name: /watch/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /add to list/i })).not.toBeInTheDocument();
@@ -92,7 +134,12 @@ describe('ExtractedDataTable', () => {
it('should highlight watched items and hide the watch button', () => {
vi.mocked(useUserData).mockReturnValue({
watchedItems: [mockMasterItems[0]], // 'Apples' is watched
} as any);
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
const appleItemRow = screen.getByText('Gala Apples').closest('tr');
expect(appleItemRow).toHaveTextContent('Gala Apples');
@@ -125,11 +172,18 @@ describe('ExtractedDataTable', () => {
activeListId: 1,
addItemToList: mockAddItemToList,
shoppingLists: mockShoppingLists, // 'Milk' is in this list
} as any);
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
const milkItemRow = screen.getByText('2% Milk').closest('tr')!;
// Use a more specific query to target the "shopping list" button and not the "watchlist" button.
const addToListButton = within(milkItemRow).queryByTitle(/add to your shopping list/i);
const addToListButton = within(milkItemRow).queryByTitle(/add milk to list/i);
expect(addToListButton).not.toBeInTheDocument();
});
@@ -138,7 +192,14 @@ describe('ExtractedDataTable', () => {
activeListId: 1,
addItemToList: mockAddItemToList,
shoppingLists: [{ ...mockShoppingLists[0], items: [] }], // Empty list
} as any);
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
const appleItemRow = screen.getByText('Gala Apples').closest('tr')!;
// Correct the title query to match the actual rendered title.
@@ -146,13 +207,22 @@ describe('ExtractedDataTable', () => {
expect(addToListButton).toBeInTheDocument();
fireEvent.click(addToListButton!);
expect(mockAddItemToList).toHaveBeenCalledWith(1, { masterItemId: 1 });
expect(mockAddItemToList).toHaveBeenCalledWith(1, { masterItemId: 1 }); // The component calls the hook's function
});
it('should disable the add to list button if no list is active', () => {
vi.mocked(useShoppingLists).mockReturnValue({
activeListId: null,
} as any);
shoppingLists: [],
addItemToList: mockAddItemToList,
setActiveListId: vi.fn(),
createList: vi.fn(),
deleteList: vi.fn(),
updateItemInList: vi.fn(),
removeItemFromList: vi.fn(),
isCreatingList: false, isDeletingList: false, isAddingItem: false, isUpdatingItem: false, isRemovingItem: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
// Multiple buttons will have this title, so we must use `getAllByTitle`.
const addToListButtons = screen.getAllByTitle('Select a shopping list first');
@@ -175,7 +245,12 @@ describe('ExtractedDataTable', () => {
// Watch 'Chicken Breast' (normally 3rd) and 'Apples' (normally 1st)
vi.mocked(useUserData).mockReturnValue({
watchedItems: [mockMasterItems[2], mockMasterItems[0]],
} as any);
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
// Get all rows from the table body

View File

@@ -1,5 +1,5 @@
// src/features/flyer/ExtractedDataTable.tsx
import React, { useMemo, useState } from 'react';
import React, { useMemo, useState, useCallback } from 'react';
import type { FlyerItem, MasterGroceryItem, ShoppingList, ShoppingListItem, User } from '../../types'; // Import ShoppingListItem
import { formatUnitPrice } from '../../utils/unitConverter';
import { PlusCircleIcon } from '../../components/icons/PlusCircleIcon';
@@ -11,18 +11,10 @@ import { useShoppingLists } from '../../hooks/useShoppingLists';
export interface ExtractedDataTableProps {
items: FlyerItem[];
totalActiveItems?: number;
watchedItems: MasterGroceryItem[];
masterItems: MasterGroceryItem[];
unitSystem: 'metric' | 'imperial';
user: User | null;
onAddItem: (itemName: string, category: string) => Promise<void>;
shoppingLists: ShoppingList[];
activeListId: number | null;
onAddItemToList: (masterItemId: number) => void;
}
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, totalActiveItems, unitSystem }) => {
export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, unitSystem }) => {
const { user } = useAuth();
const { watchedItems } = useUserData();
const { masterItems } = useMasterItems();
@@ -40,6 +32,11 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
return new Set(activeList.items.map((item: ShoppingListItem) => item.master_item_id));
}, [shoppingLists, activeListId]);
const handleAddItemToList = useCallback((masterItemId: number) => {
if (!activeListId) return;
addItemToList(activeListId, { masterItemId });
}, [activeListId, addItemToList]);
const availableCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category_name).filter((c): c is string => !!c));
return Array.from(cats).sort();
@@ -82,9 +79,7 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
);
}
const title = (totalActiveItems && totalActiveItems > 0)
? `Item List (${items.length} in flyer / ${totalActiveItems} total active deals)`
: `Item List (${items.length})`;
const title = `Item List (${items.length})`;
return (
<div className="overflow-hidden bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm">
@@ -133,7 +128,7 @@ export const ExtractedDataTable: React.FC<ExtractedDataTableProps> = ({ items, t
<div className="flex items-center space-x-2 shrink-0 ml-4">
{user && canonicalName && !isInList && (
<button
onClick={() => addItemToList(activeListId!, { masterItemId: item.master_item_id! })}
onClick={() => handleAddItemToList(item.master_item_id!)}
disabled={!activeListId}
className="text-gray-400 hover:text-brand-primary disabled:text-gray-300 disabled:cursor-not-allowed dark:text-gray-500 dark:hover:text-brand-light transition-colors"
title={activeListId ? `Add ${canonicalName} to list` : 'Select a shopping list first'}

View File

@@ -41,10 +41,21 @@ describe('useActiveDeals Hook', () => {
// Set up default successful mocks for the new data hooks
mockedUseFlyers.mockReturnValue({
flyers: mockFlyers,
} as any);
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
mockedUseUserData.mockReturnValue({
watchedItems: mockWatchedItems,
} as any);
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
});
afterEach(() => {
@@ -106,7 +117,14 @@ describe('useActiveDeals Hook', () => {
it('should not fetch flyer items if there are no watched items', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
mockedUseUserData.mockReturnValue({ watchedItems: [] } as any); // Override for this test
mockedUseUserData.mockReturnValue({
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
}); // Override for this test
const { result } = renderHook(() => useActiveDeals());
@@ -121,7 +139,15 @@ describe('useActiveDeals Hook', () => {
it('should handle the case where there are no valid flyers', async () => {
// Override flyers mock to only include invalid ones
mockedUseFlyers.mockReturnValue({ flyers: [mockFlyers[1], mockFlyers[2]] } as any);
mockedUseFlyers.mockReturnValue({
flyers: [mockFlyers[1], mockFlyers[2]],
isLoadingFlyers: false,
flyersError: null,
fetchNextFlyersPage: vi.fn(),
hasNextFlyersPage: false,
isRefetchingFlyers: false,
refetchFlyers: vi.fn(),
});
const { result } = renderHook(() => useActiveDeals());
await waitFor(() => {

View File

@@ -1,5 +1,5 @@
// src/hooks/useAiAnalysis.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAiAnalysis } from './useAiAnalysis';
import { useApi } from './useApi';
@@ -18,11 +18,11 @@ vi.mock('../services/logger.client', () => ({
const mockedUseApi = vi.mocked(useApi);
// --- Mocks for each useApi instance ---
const mockGetQuickInsights = { execute: vi.fn(), data: null, loading: false, error: null };
const mockGetDeepDive = { execute: vi.fn(), data: null, loading: false, error: null };
const mockSearchWeb = { execute: vi.fn(), data: null, loading: false, error: null };
const mockPlanTrip = { execute: vi.fn(), data: null, loading: false, error: null };
const mockComparePrices = { execute: vi.fn(), data: null, loading: false, error: null };
const mockGetQuickInsights = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
const mockGetDeepDive = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
const mockSearchWeb = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
const mockPlanTrip = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
const mockComparePrices = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
const mockGenerateImage = { execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false };
// 2. Mock data
@@ -41,12 +41,12 @@ describe('useAiAnalysis Hook', () => {
vi.clearAllMocks();
// Reset the mock implementations for each test
mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights as any)
.mockReturnValueOnce(mockGetDeepDive as any)
.mockReturnValueOnce(mockSearchWeb as any)
.mockReturnValueOnce(mockPlanTrip as any)
.mockReturnValueOnce(mockComparePrices as any)
.mockReturnValueOnce(mockGenerateImage as any);
.mockReturnValueOnce(mockGetQuickInsights)
.mockReturnValueOnce(mockGetDeepDive)
.mockReturnValueOnce(mockSearchWeb)
.mockReturnValueOnce(mockPlanTrip)
.mockReturnValueOnce(mockComparePrices)
.mockReturnValueOnce(mockGenerateImage);
// Mock Geolocation API
Object.defineProperty(navigator, 'geolocation', {
@@ -93,8 +93,8 @@ describe('useAiAnalysis Hook', () => {
// Simulate useApi returning new data by re-rendering with a new mock value
mockedUseApi.mockReset()
.mockReturnValueOnce({ ...mockGetQuickInsights, data: 'New insights' } as any)
.mockReturnValue(mockGetDeepDive as any); // provide defaults for others
.mockReturnValueOnce({ ...mockGetQuickInsights, data: 'New insights' })
.mockReturnValue(mockGetDeepDive); // provide defaults for others
rerender();
@@ -119,8 +119,8 @@ describe('useAiAnalysis Hook', () => {
mockedUseApi.mockReset()
.mockReturnValue(mockGetQuickInsights as any)
.mockReturnValue(mockGetDeepDive as any)
.mockReturnValueOnce({ ...mockSearchWeb, data: mockResponse } as any);
.mockReturnValue(mockGetDeepDive)
.mockReturnValueOnce({ ...mockSearchWeb, data: mockResponse });
rerender();
@@ -160,7 +160,7 @@ describe('useAiAnalysis Hook', () => {
// Simulate useApi returning an error
mockedUseApi.mockReset()
.mockReturnValueOnce({ ...mockGetQuickInsights, error: apiError } as any);
.mockReturnValueOnce({ ...mockGetQuickInsights, error: apiError });
const { result } = renderHook(() => useAiAnalysis(defaultParams));
@@ -231,10 +231,10 @@ describe('useAiAnalysis Hook', () => {
// Re-mock the useApi for generateImage to return the error
mockedUseApi.mockReset()
.mockReturnValue(mockGetQuickInsights as any).mockReturnValue(mockGetDeepDive as any)
.mockReturnValue(mockSearchWeb as any).mockReturnValue(mockPlanTrip as any)
.mockReturnValue(mockComparePrices as any)
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError } as any);
.mockReturnValue(mockGetQuickInsights).mockReturnValue(mockGetDeepDive)
.mockReturnValue(mockSearchWeb).mockReturnValue(mockPlanTrip)
.mockReturnValue(mockComparePrices)
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError });
await act(async () => {
await result.current.generateImage();

View File

@@ -119,8 +119,6 @@ describe('HomePage Component', () => {
const props = ExtractedDataTableMock.mock.calls[ExtractedDataTableMock.mock.calls.length - 1][0];
expect(props.items).toEqual(mockItems);
expect(props.masterItems).toEqual(mockContext.masterItems);
expect(props.onAddItem).toBe(mockContext.addWatchedItem);
});
});
});

View File

@@ -1,19 +1,9 @@
// src/pages/HomePage.tsx
import React from 'react';
import { useOutletContext } from 'react-router-dom';
import { FlyerDisplay } from '../features/flyer/FlyerDisplay';
import { ExtractedDataTable } from '../features/flyer/ExtractedDataTable';
import { AnalysisPanel } from '../features/flyer/AnalysisPanel';
import type { Flyer, FlyerItem, MasterGroceryItem, ShoppingList } from '../types';
interface HomePageContext {
totalActiveItems: number;
masterItems: MasterGroceryItem[];
addWatchedItem: (itemName: string, category: string) => Promise<void>;
shoppingLists: ShoppingList[];
activeListId: number | null;
addItemToList: (listId: number, item: { masterItemId?: number; customItemName?: string; }) => Promise<void>;
}
import type { Flyer, FlyerItem } from '../types';
interface HomePageProps {
selectedFlyer: Flyer | null;
@@ -22,8 +12,7 @@ interface HomePageProps {
}
export const HomePage: React.FC<HomePageProps> = ({ selectedFlyer, flyerItems, onOpenCorrectionTool }) => {
const { totalActiveItems, masterItems, addWatchedItem, shoppingLists, activeListId, addItemToList } = useOutletContext<HomePageContext>();
const hasData = flyerItems.length > 0;
const hasData = flyerItems.length > 0;
if (!selectedFlyer) {
return (
@@ -48,17 +37,9 @@ export const HomePage: React.FC<HomePageProps> = ({ selectedFlyer, flyerItems, o
<>
<ExtractedDataTable
items={flyerItems}
totalActiveItems={totalActiveItems}
watchedItems={[]} // Sourced from useWatchedItems in layout
masterItems={masterItems}
unitSystem={'imperial'} // Sourced from context/props
user={null} // Sourced from useAuth in layout
onAddItem={addWatchedItem}
shoppingLists={shoppingLists}
activeListId={activeListId}
onAddItemToList={(masterItemId: number) => activeListId && addItemToList(activeListId, { masterItemId })}
/>
<AnalysisPanel flyerItems={flyerItems} store={selectedFlyer.store} />
<AnalysisPanel selectedFlyer={selectedFlyer} />
</>
)}
</>

View File

@@ -239,6 +239,16 @@ export interface ItemPriceHistory {
data_points_count: number;
}
/**
* Represents a single data point for an item's price on a specific day.
* This is the raw structure returned by the price history API endpoint.
*/
export interface HistoricalPriceDataPoint {
master_item_id: number;
avg_price_in_cents: number | null;
summary_date: string; // DATE
}
export interface MasterItemAlias {
master_item_alias_id: number;
master_item_id: number;