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
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 3h11m16s
This commit is contained in:
@@ -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'));
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
10
src/types.ts
10
src/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user