// src/features/flyer/ExtractedDataTable.test.tsx import React from 'react'; import { render, screen, fireEvent, within, prettyDOM } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ExtractedDataTable, ExtractedDataTableProps } from './ExtractedDataTable'; import type { FlyerItem, MasterGroceryItem, ShoppingList } from '../../types'; import { useAuth } from '../../hooks/useAuth'; import { createMockFlyerItem, createMockMasterGroceryItem, createMockShoppingList, createMockShoppingListItem, createMockUser, createMockUserProfile, } from '../../tests/utils/mockFactories'; import { useUserData } from '../../hooks/useUserData'; import { useMasterItems } from '../../hooks/useMasterItems'; import { useWatchedItems } from '../../hooks/useWatchedItems'; import { useShoppingLists } from '../../hooks/useShoppingLists'; // Mock all the data hooks vi.mock('../../hooks/useAuth'); vi.mock('../../hooks/useUserData'); vi.mock('../../hooks/useMasterItems'); vi.mock('../../hooks/useWatchedItems'); vi.mock('../../hooks/useShoppingLists'); const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' }); const mockUserProfile = createMockUserProfile({ user: mockUser }); const mockMasterItems: MasterGroceryItem[] = [ createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', }), createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', }), createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Chicken Breast', category_id: 3, category_name: 'Meat', }), ]; const mockFlyerItems: FlyerItem[] = [ createMockFlyerItem({ flyer_item_id: 101, item: 'Gala Apples', price_display: '$1.99/lb', price_in_cents: 199, quantity: 'per lb', unit_price: { value: 1.99, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1, }), createMockFlyerItem({ flyer_item_id: 102, item: '2% Milk', price_display: '$4.50', price_in_cents: 450, quantity: '4L', unit_price: { value: 1.125, unit: 'L' }, master_item_id: 2, category_name: 'Dairy', flyer_id: 1, }), createMockFlyerItem({ flyer_item_id: 103, item: 'Boneless Chicken', price_display: '$8.00/kg', price_in_cents: 800, quantity: 'per kg', unit_price: { value: 8.0, unit: 'kg' }, master_item_id: 3, category_name: 'Meat', flyer_id: 1, }), createMockFlyerItem({ flyer_item_id: 104, item: 'Mystery Soda', price_display: '$1.00', price_in_cents: 100, quantity: '1 can', unit_price: { value: 1.0, unit: 'can' }, master_item_id: undefined, category_name: 'Beverages', flyer_id: 1, }), // Unmatched item createMockFlyerItem({ flyer_item_id: 105, item: 'Apples', price_display: '$2.50/lb', price_in_cents: 250, quantity: 'per lb', unit_price: { value: 2.5, unit: 'lb' }, master_item_id: 1, category_name: 'Produce', flyer_id: 1, }), // Item name matches canonical name ]; const mockShoppingLists: ShoppingList[] = [ createMockShoppingList({ shopping_list_id: 1, name: 'My List', user_id: 'user-123', items: [ createMockShoppingListItem({ shopping_list_item_id: 1, shopping_list_id: 1, master_item_id: 2, quantity: 1, is_purchased: false, }), ], // Contains Milk }), ]; const mockAddWatchedItem = vi.fn(); const mockAddItemToList = vi.fn(); 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({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); vi.mocked(useUserData).mockReturnValue({ watchedItems: [], shoppingLists: mockShoppingLists, setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); vi.mocked(useMasterItems).mockReturnValue({ masterItems: mockMasterItems, isLoading: false, error: null, }); vi.mocked(useWatchedItems).mockReturnValue({ addWatchedItem: mockAddWatchedItem, watchedItems: [], removeWatchedItem: vi.fn(), error: null, }); vi.mocked(useShoppingLists).mockReturnValue({ activeListId: 1, addItemToList: mockAddItemToList, shoppingLists: mockShoppingLists, // Add this to satisfy the component's dependency 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', () => { render(); expect(screen.getByText('No items extracted yet.')).toBeInTheDocument(); }); it('should render the table with items', () => { render(); expect(screen.getByRole('heading', { name: /item list/i })).toBeInTheDocument(); expect(screen.getByText('Gala Apples')).toBeInTheDocument(); expect(screen.getByText('2% Milk')).toBeInTheDocument(); }); it('should not show watch/add to list buttons for anonymous users', () => { vi.mocked(useAuth).mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); render(); expect(screen.queryByRole('button', { name: /watch/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /add to list/i })).not.toBeInTheDocument(); }); describe('Item States and Interactions', () => { it('should highlight watched items and hide the watch button', () => { vi.mocked(useUserData).mockReturnValue({ watchedItems: [mockMasterItems[0]], // 'Apples' is watched shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); render(); const appleItemRow = screen.getByText('Gala Apples').closest('tr'); expect(appleItemRow).toHaveTextContent('Gala Apples'); expect(appleItemRow!.querySelector('.font-bold')).toBeInTheDocument(); // Watched items are bold expect(appleItemRow!.querySelector('button[title*="Watch"]')).not.toBeInTheDocument(); }); it('should show the watch button for unwatched, matched items', () => { render(); // Find the specific table row for the item first to make the test more specific. const chickenItemRow = screen.getByText('Boneless Chicken').closest('tr')!; // Now, find the watch button *within* that row. const watchButton = within(chickenItemRow).getByTitle( "Add 'Chicken Breast' to your watchlist", ); expect(watchButton).toBeInTheDocument(); fireEvent.click(watchButton); expect(mockAddWatchedItem).toHaveBeenCalledWith('Chicken Breast', 'Meat'); }); it('should not show watch or add to list buttons for unmatched items', () => { render(); const sodaItemRow = screen.getByText('Mystery Soda').closest('tr'); expect(sodaItemRow!.querySelector('button[title*="Watch"]')).not.toBeInTheDocument(); expect(sodaItemRow!.querySelector('button[title*="list"]')).not.toBeInTheDocument(); }); it('should hide the add to list button for items already in the active list', () => { // 'Milk' (master_item_id: 2) is in the active shopping list vi.mocked(useShoppingLists).mockReturnValue({ activeListId: 1, addItemToList: mockAddItemToList, shoppingLists: mockShoppingLists, // 'Milk' is in this list 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(); 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 milk to list/i); expect(addToListButton).not.toBeInTheDocument(); }); it('should show the add to list button for items not in the list', () => { vi.mocked(useShoppingLists).mockReturnValue({ activeListId: 1, addItemToList: mockAddItemToList, shoppingLists: [{ ...mockShoppingLists[0], items: [] }], // Empty list 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(); const appleItemRow = screen.getByText('Gala Apples').closest('tr')!; // Correct the title query to match the actual rendered title. const addToListButton = within(appleItemRow).getByTitle('Add Apples to list'); expect(addToListButton).toBeInTheDocument(); fireEvent.click(addToListButton!); 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, 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(); // Multiple buttons will have this title, so we must use `getAllByTitle`. const addToListButtons = screen.getAllByTitle('Select a shopping list first'); // Assert that at least one such button exists and that they are all disabled. expect(addToListButtons.length).toBeGreaterThan(0); addToListButtons.forEach((button) => expect(button).toBeDisabled()); }); it('should display the canonical name when it differs from the item name', () => { render(); // For 'Gala Apples', canonical is 'Apples' expect(screen.getByText('(Canonical: Apples)')).toBeInTheDocument(); // For '2% Milk', canonical is 'Milk' expect(screen.getByText('(Canonical: Milk)')).toBeInTheDocument(); }); it('should not display the canonical name when it is the same as the item name (case-insensitive)', () => { render(); // Find the row for the item where item.name === canonical.name const matchingNameItemRow = screen.getByText('$2.50/lb').closest('tr')!; // Ensure the redundant canonical name is not displayed expect(within(matchingNameItemRow).queryByText(/\(Canonical: .*\)/)).not.toBeInTheDocument(); }); }); describe('Sorting and Filtering', () => { it('should sort watched items to the top', () => { // Watch 'Chicken Breast' (normally 3rd) and 'Apples' (normally 1st) vi.mocked(useUserData).mockReturnValue({ watchedItems: [mockMasterItems[2], mockMasterItems[0]], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); render(); // Get all rows from the table body const rows = screen.getAllByRole('row'); // Extract the primary item name from each row to check the sort order const itemNamesInOrder = rows.map( (row) => row.querySelector('div.font-semibold, div.font-bold')?.textContent, ); // Assert the order is correct: watched items first, then others. // 'Gala Apples' (101) and 'Apples' (105) both have master_item_id 1, which is watched. // The implementation sorts watched items to the top, and then sorts alphabetically within each group (watched/unwatched). const expectedOrder = [ 'Apples', // Watched 'Boneless Chicken', // Watched 'Gala Apples', // Watched '2% Milk', 'Mystery Soda', // Unwatched ]; expect(itemNamesInOrder).toEqual(expectedOrder); }); it('should filter items by category', () => { render(); const categoryFilter = screen.getByLabelText('Filter by category'); // Initial state expect(screen.getByText('Gala Apples')).toBeInTheDocument(); expect(screen.getByText('2% Milk')).toBeInTheDocument(); // Filter by Dairy fireEvent.change(categoryFilter, { target: { value: 'Dairy' } }); expect(screen.queryByText('Gala Apples')).not.toBeInTheDocument(); expect(screen.getByText('2% Milk')).toBeInTheDocument(); // Check empty state for filter fireEvent.change(categoryFilter, { target: { value: 'Produce' } }); fireEvent.change(categoryFilter, { target: { value: 'Snacks' } }); // A category with no items expect(screen.getByText('No items found for the selected category.')).toBeInTheDocument(); }); it('should not render the category filter if there is only one category', () => { const singleCategoryItems = mockFlyerItems.filter((item) => item.category_name === 'Produce'); render(); expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument(); }); it('should allow switching filter back to All Categories', () => { render(); const categoryFilter = screen.getByLabelText('Filter by category'); // Filter to Dairy fireEvent.change(categoryFilter, { target: { value: 'Dairy' } }); expect(screen.queryByText('Gala Apples')).not.toBeInTheDocument(); expect(screen.getByText('2% Milk')).toBeInTheDocument(); // Filter back to All fireEvent.change(categoryFilter, { target: { value: 'all' } }); expect(screen.getByText('Gala Apples')).toBeInTheDocument(); expect(screen.getByText('2% Milk')).toBeInTheDocument(); }); it('should sort items alphabetically within watched and unwatched groups', () => { const items = [ createMockFlyerItem({ flyer_item_id: 1, item: 'Yam', master_item_id: 3, category_name: 'Produce', }), // Unwatched createMockFlyerItem({ flyer_item_id: 2, item: 'Zebra', master_item_id: 1, category_name: 'Produce', }), // Watched createMockFlyerItem({ flyer_item_id: 3, item: 'Banana', master_item_id: 4, category_name: 'Produce', }), // Unwatched createMockFlyerItem({ flyer_item_id: 4, item: 'Apple', master_item_id: 2, category_name: 'Produce', }), // Watched ]; vi.mocked(useUserData).mockReturnValue({ watchedItems: [ createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Zebra' }), createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Apple' }), ], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); render(); const rows = screen.getAllByRole('row'); // Extract item names based on the bold/semibold classes used for names const itemNames = rows.map((row) => { const nameEl = row.querySelector('.font-bold, .font-semibold'); return nameEl?.textContent; }); // Expected: Watched items first (Apple, Zebra), then Unwatched (Banana, Yam) expect(itemNames).toEqual(['Apple', 'Zebra', 'Banana', 'Yam']); }); }); describe('Data Edge Cases', () => { it('should render correctly when masterItems list is empty', () => { vi.mocked(useMasterItems).mockReturnValue({ masterItems: [], // No master items isLoading: false, error: null, }); render(); // No canonical names should be resolved or displayed expect(screen.queryByText(/\(Canonical: .*\)/)).not.toBeInTheDocument(); // If canonical name isn't resolved (because masterItems is empty), the Add to list button should NOT appear const appleItemRow = screen.getByText('Gala Apples').closest('tr')!; expect( within(appleItemRow).queryByTitle('Select a shopping list first'), ).not.toBeInTheDocument(); }); it('should correctly format unit price for metric system', () => { render(); const chickenItemRow = screen.getByText('Boneless Chicken').closest('tr')!; // --- Debugging Logs Start --- console.log('--- DEBUG: Metric System Test Case ---'); // 1. Log the full HTML structure of the row to see how text is nested console.log('DOM Structure:', prettyDOM(chickenItemRow)); // 2. Log all text content in the row to verify if '$8.00' exists in isolation console.log('Row Text Content:', chickenItemRow?.textContent); // 3. Find all elements that look like price containers to see their exact text const potentialPrices = within(chickenItemRow).queryAllByText(/\$/); console.log(`Found ${potentialPrices.length} elements containing "$":`); potentialPrices.forEach((el, idx) => { console.log(` [${idx}] Tag: <${el.tagName.toLowerCase()}>, Text: "${el.textContent}"`); }); // 4. Test if strict matching is the cause of failure const strictMatch = within(chickenItemRow).queryByText('$8.00'); const laxMatch = within(chickenItemRow).queryByText('$8.00', { exact: false }); console.log(`Match Check: Strict='$8.00' found? ${!!strictMatch}`); console.log(`Match Check: Lax='$8.00' found? ${!!laxMatch}`); console.log('--- DEBUG End ---'); // --- Debugging Logs End --- // Fix: Use { exact: false } because the text is "$8.00/kg" (broken into "$8.00" + "/kg" or just one string) // Based on the error log provided, it is likely inside a single span as "$8.00/kg" expect(within(chickenItemRow).getByText('$8.00', { exact: false })).toBeInTheDocument(); // Check for the unit suffix, which might be in a separate element or part of the string expect(within(chickenItemRow).getAllByText(/\/kg/i).length).toBeGreaterThan(0); }); it('should handle activeListId pointing to a non-existent list', () => { vi.mocked(useShoppingLists).mockReturnValue({ activeListId: 999, // Non-existent shoppingLists: mockShoppingLists, 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(); // Should behave as if item is not in list (Add button enabled) const appleItemRow = screen.getByText('Gala Apples').closest('tr')!; const addToListButton = within(appleItemRow).getByTitle('Add Apples to list'); expect(addToListButton).toBeInTheDocument(); expect(addToListButton).not.toBeDisabled(); }); it('should display numeric quantity in parentheses if available', () => { const itemWithQtyNum = createMockFlyerItem({ flyer_item_id: 999, item: 'Bulk Rice', quantity: 'Bag', quantity_num: 5, unit_price: { value: 10, unit: 'kg' }, category_name: 'Pantry', flyer_id: 1, }); render(); expect(screen.getByText('(5)')).toBeInTheDocument(); }); }); });