Files
flyer-crawler.projectium.com/src/features/flyer/ExtractedDataTable.test.tsx
Torben Sorensen a42ee5a461
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
unit tests - wheeee! Claude is the mvp
2026-01-09 21:59:09 -08:00

628 lines
23 KiB
TypeScript

// 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,
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(<ExtractedDataTable items={[]} unitSystem="imperial" />);
expect(screen.getByText('No items extracted yet.')).toBeInTheDocument();
});
it('should render the table with items', () => {
render(<ExtractedDataTable {...defaultProps} />);
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(<ExtractedDataTable {...defaultProps} />);
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: [],
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
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(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} />);
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(<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 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(<ExtractedDataTable {...defaultProps} />);
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(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} />);
// 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: [],
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} />);
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(<ExtractedDataTable {...defaultProps} items={singleCategoryItems} />);
expect(screen.queryByLabelText('Filter by category')).not.toBeInTheDocument();
});
it('should allow switching filter back to All Categories', () => {
render(<ExtractedDataTable {...defaultProps} />);
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: [],
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} items={items} />);
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(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} unitSystem="metric" />);
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(<ExtractedDataTable {...defaultProps} />);
// 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(<ExtractedDataTable {...defaultProps} items={[itemWithQtyNum]} />);
expect(screen.getByText('(5)')).toBeInTheDocument();
});
it('should use fallback category when adding to watchlist for items without category_name', () => {
const itemWithoutCategory = createMockFlyerItem({
flyer_item_id: 999,
item: 'Mystery Item',
master_item_id: 10,
category_name: undefined,
flyer_id: 1,
});
// Mock masterItems to include a matching item for canonical name resolution
vi.mocked(useMasterItems).mockReturnValue({
masterItems: [
createMockMasterGroceryItem({
master_grocery_item_id: 10,
name: 'Canonical Mystery',
}),
],
isLoading: false,
error: null,
});
render(<ExtractedDataTable {...defaultProps} items={[itemWithoutCategory]} />);
const itemRow = screen.getByText('Mystery Item').closest('tr')!;
const watchButton = within(itemRow).getByTitle("Add 'Canonical Mystery' to your watchlist");
fireEvent.click(watchButton);
expect(mockAddWatchedItem).toHaveBeenCalledWith('Canonical Mystery', 'Other/Miscellaneous');
});
it('should not call addItemToList when activeListId is null and button is clicked', () => {
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(<ExtractedDataTable {...defaultProps} />);
// Even with disabled button, test the handler logic by verifying no call is made
// The buttons are disabled but we verify that even if clicked, no action occurs
const addToListButtons = screen.getAllByTitle('Select a shopping list first');
expect(addToListButtons.length).toBeGreaterThan(0);
// Click the button (even though disabled)
fireEvent.click(addToListButtons[0]);
// addItemToList should not be called because activeListId is null
expect(mockAddItemToList).not.toHaveBeenCalled();
});
});
});