// src/features/shopping/ShoppingList.test.tsx import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { ShoppingListComponent } from './ShoppingList'; // This path is now relative to the new folder import type { ShoppingList } from '../../types'; import * as aiApiClient from '../../services/aiApiClient'; import { createMockShoppingList, createMockShoppingListItem, createMockUser, } from '../../tests/utils/mockFactories'; // The logger and aiApiClient are now mocked globally. // Mock the AI API client (relative to new location) // We will spy on the function directly in the test instead of mocking the whole module. const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' }); const mockLists: ShoppingList[] = [ createMockShoppingList({ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123', items: [ createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, master_item_id: 1, custom_item_name: null, master_item: { name: 'Apples' }, }), createMockShoppingListItem({ shopping_list_item_id: 102, shopping_list_id: 1, master_item_id: null, custom_item_name: 'Special Bread', master_item: null, }), createMockShoppingListItem({ shopping_list_item_id: 103, shopping_list_id: 1, master_item_id: 2, is_purchased: true, custom_item_name: null, master_item: { name: 'Milk' }, }), createMockShoppingListItem({ shopping_list_item_id: 104, shopping_list_id: 1, master_item_id: null, custom_item_name: null, master_item: null, }), // Item with no name ], }), createMockShoppingList({ shopping_list_id: 2, name: 'Party Supplies', user_id: 'user-123', items: [], }), ]; describe('ShoppingListComponent (in shopping feature)', () => { const mockOnSelectList = vi.fn(); const mockOnCreateList = vi.fn(); const mockOnDeleteList = vi.fn(); const mockOnAddItem = vi.fn(); const mockOnUpdateItem = vi.fn(); const mockOnRemoveItem = vi.fn(); const defaultProps = { user: mockUser, lists: mockLists, activeListId: 1, onSelectList: mockOnSelectList, onCreateList: mockOnCreateList, onDeleteList: mockOnDeleteList, onAddItem: mockOnAddItem, onUpdateItem: mockOnUpdateItem, onRemoveItem: mockOnRemoveItem, }; beforeEach(() => { vi.clearAllMocks(); // Mock browser APIs window.prompt = vi.fn(); window.confirm = vi.fn(); window.AudioContext = vi.fn().mockImplementation(() => ({ decodeAudioData: vi.fn().mockResolvedValue({}), createBufferSource: vi.fn().mockReturnValue({ connect: vi.fn(), start: vi.fn(), buffer: {}, }), close: vi.fn(), sampleRate: 44100, })); }); // Restore all mocks after each test to ensure test isolation. afterEach(() => { vi.restoreAllMocks(); }); it('should render a login message when user is not authenticated', () => { render(); expect(screen.getByText(/please log in to manage your shopping lists/i)).toBeInTheDocument(); }); it('should render correctly when authenticated with an active list', () => { render(); expect(screen.getByRole('heading', { name: /shopping list/i })).toBeInTheDocument(); expect(screen.getByRole('combobox')).toHaveValue('1'); expect(screen.getByText('Apples')).toBeInTheDocument(); expect(screen.getByText('Special Bread')).toBeInTheDocument(); expect(screen.getByText('Milk')).toBeInTheDocument(); // Purchased item expect(screen.getByRole('heading', { name: /purchased/i })).toBeInTheDocument(); }); it('should display a message if there are no lists', () => { render(); expect(screen.getByText(/no shopping lists found/i)).toBeInTheDocument(); }); it('should call onSelectList when changing the list in the dropdown', () => { render(); fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } }); expect(mockOnSelectList).toHaveBeenCalledWith(2); }); it('should call onCreateList when creating a new list', async () => { (window.prompt as Mock).mockReturnValue('New List Name'); render(); fireEvent.click(screen.getByRole('button', { name: /new list/i })); await waitFor(() => { expect(mockOnCreateList).toHaveBeenCalledWith('New List Name'); }); }); it('should not call onCreateList if prompt is cancelled', async () => { (window.prompt as Mock).mockReturnValue(null); render(); fireEvent.click(screen.getByRole('button', { name: /new list/i })); await waitFor(() => { expect(mockOnCreateList).not.toHaveBeenCalled(); }); }); it('should call onDeleteList when deleting a list after confirmation', async () => { (window.confirm as Mock).mockReturnValue(true); render(); fireEvent.click(screen.getByRole('button', { name: /delete list/i })); await waitFor(() => { expect(window.confirm).toHaveBeenCalled(); expect(mockOnDeleteList).toHaveBeenCalledWith(1); }); }); it('should not call onDeleteList if deletion is not confirmed', async () => { (window.confirm as Mock).mockReturnValue(false); render(); fireEvent.click(screen.getByRole('button', { name: /delete list/i })); await waitFor(() => { expect(window.confirm).toHaveBeenCalled(); expect(mockOnDeleteList).not.toHaveBeenCalled(); }); }); it('should call onAddItem when adding a custom item', async () => { render(); const input = screen.getByPlaceholderText(/add a custom item/i); const addButton = screen.getByRole('button', { name: 'Add' }); fireEvent.change(input, { target: { value: 'New Custom Item' } }); fireEvent.click(addButton); // Assert that the callback was called with the correct value. expect(mockOnAddItem).toHaveBeenCalledWith({ customItemName: 'New Custom Item' }); // Also, verify that the input field was cleared after submission. await waitFor(() => { expect(input).toHaveValue(''); }); }); it('should call onUpdateItem when toggling an item checkbox', () => { render(); const checkboxes = screen.getAllByRole('checkbox'); const appleCheckbox = checkboxes[0]; // 'Apples' is the first item fireEvent.click(appleCheckbox); expect(mockOnUpdateItem).toHaveBeenCalledWith(101, { is_purchased: true }); }); it('should call onRemoveItem when clicking the remove button', () => { render(); // Items are in a div, not listitem. We find the button near the item text. const appleItem = screen.getByText('Apples').closest('div'); const removeButton = appleItem!.querySelector('button')!; fireEvent.click(removeButton); expect(mockOnRemoveItem).toHaveBeenCalledWith(101); }); it('should call generateSpeechFromText when "Read aloud" is clicked', async () => { const generateSpeechSpy = vi.spyOn(aiApiClient, 'generateSpeechFromText').mockResolvedValue({ json: () => Promise.resolve('base64-audio-string'), } as Response); render(); const readAloudButton = screen.getByTitle(/read list aloud/i); fireEvent.click(readAloudButton); await waitFor(() => { expect(generateSpeechSpy).toHaveBeenCalledWith( 'Here is your shopping list: Apples, Special Bread', ); }); }); it('should show an alert if reading aloud fails', async () => { const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue(new Error('AI API is down')); render(); const readAloudButton = screen.getByTitle(/read list aloud/i); fireEvent.click(readAloudButton); await waitFor(() => { expect(alertSpy).toHaveBeenCalledWith('Could not read list aloud: AI API is down'); }); alertSpy.mockRestore(); }); it('should show a generic alert if reading aloud fails with a non-Error object', async () => { const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {}); vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue('A string error'); render(); const readAloudButton = screen.getByTitle(/read list aloud/i); fireEvent.click(readAloudButton); await waitFor(() => { expect(alertSpy).toHaveBeenCalledWith( 'Could not read list aloud: An unknown error occurred while generating audio.', ); }); alertSpy.mockRestore(); }); it('should handle interactions with purchased items', () => { render(); // Find the purchased item 'Milk' const milkItem = screen.getByText('Milk').closest('div'); expect(milkItem).toBeInTheDocument(); // Test un-checking the item const checkbox = milkItem!.querySelector('input[type="checkbox"]'); expect(checkbox).toBeChecked(); fireEvent.click(checkbox!); expect(mockOnUpdateItem).toHaveBeenCalledWith(103, { is_purchased: false }); // Test removing the item const removeButton = milkItem!.querySelector('button'); expect(removeButton).toBeInTheDocument(); fireEvent.click(removeButton!); expect(mockOnRemoveItem).toHaveBeenCalledWith(103); }); describe('Loading States and Disabled States', () => { it('should disable the "Add" button for custom items when input is empty or whitespace', () => { render(); const input = screen.getByPlaceholderText(/add a custom item/i); const addButton = screen.getByRole('button', { name: 'Add' }); expect(addButton).toBeDisabled(); fireEvent.change(input, { target: { value: ' ' } }); expect(addButton).toBeDisabled(); fireEvent.change(input, { target: { value: 'Something' } }); expect(addButton).toBeEnabled(); }); it('should show a loading spinner while adding a custom item', async () => { let resolvePromise: (value: void | PromiseLike) => void; const mockPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockOnAddItem.mockReturnValue(mockPromise); render(); const input = screen.getByPlaceholderText(/add a custom item/i); const addButton = screen.getByRole('button', { name: 'Add' }); fireEvent.change(input, { target: { value: 'Loading Item' } }); fireEvent.click(addButton); await waitFor(() => { expect(addButton).toBeDisabled(); expect(addButton.querySelector('.animate-spin')).toBeInTheDocument(); }); // Resolve promise to avoid test warnings await act(async () => { resolvePromise(); await mockPromise; }); }); it('should show a loading spinner while creating a new list', async () => { let resolvePromise: (value: void | PromiseLike) => void; const mockPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockOnCreateList.mockReturnValue(mockPromise); (window.prompt as Mock).mockReturnValue('New List'); render(); const newListButton = screen.getByRole('button', { name: /new list/i }); fireEvent.click(newListButton); await waitFor(() => { expect(newListButton).toBeDisabled(); }); // Resolve promise to avoid test warnings await act(async () => { resolvePromise(); await mockPromise; }); }); it('should show a loading spinner while reading the list aloud', async () => { let resolvePromise: (value: Response | PromiseLike) => void; const mockPromise = new Promise((resolve) => { resolvePromise = resolve; }); vi.spyOn(aiApiClient, 'generateSpeechFromText').mockReturnValue(mockPromise); render(); const readAloudButton = screen.getByTitle(/read list aloud/i); fireEvent.click(readAloudButton); await waitFor(() => { expect(readAloudButton).toBeDisabled(); expect(readAloudButton.querySelector('.animate-spin')).toBeInTheDocument(); }); // Resolve promise to avoid test warnings await act(async () => { resolvePromise({ json: () => Promise.resolve('audio') } as Response); await mockPromise; }); }); it('should disable the "Read aloud" button if there are no items to read', () => { const listWithOnlyPurchasedItems: ShoppingList[] = [ createMockShoppingList({ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123', items: [mockLists[0].items[2]], // Only the purchased 'Milk' item }), ]; render(); expect(screen.getByTitle(/read list aloud/i)).toBeDisabled(); }); }); describe('UI Edge Cases', () => { it('should not call onCreateList if the prompt returns an empty or whitespace string', async () => { (window.prompt as Mock).mockReturnValue(' '); render(); fireEvent.click(screen.getByRole('button', { name: /new list/i })); await waitFor(() => { expect(mockOnCreateList).not.toHaveBeenCalled(); }); }); it('should display a message for an active list with no items', () => { render(); // Party Supplies list is empty expect(screen.getByText('This list is empty.')).toBeInTheDocument(); }); it('should render an item gracefully if it has no custom name or master item name', () => { render(); // The item with ID 104 has no name. We find its checkbox to confirm it rendered. const checkboxes = screen.getAllByRole('checkbox'); // Apples, Special Bread, Nameless Item, Milk (purchased) expect(checkboxes).toHaveLength(4); const namelessItemCheckbox = checkboxes[2]; // The span next to it should be empty expect(namelessItemCheckbox.nextElementSibling).toHaveTextContent(''); }); }); });