diff --git a/src/components/BarcodeScannerView.test.tsx b/src/components/BarcodeScannerView.test.tsx new file mode 100644 index 0000000..d14f2fb --- /dev/null +++ b/src/components/BarcodeScannerView.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { BarcodeScannerView } from './BarcodeScannerView'; + +const mockUseBarcodeScanner = vi.fn(); + +vi.mock('../hooks/useBarcodeScanner', () => ({ + useBarcodeScanner: () => mockUseBarcodeScanner(), +})); + +describe('BarcodeScannerView', () => { + it('notifies when a barcode is detected and shows success alert', () => { + const onDetected = vi.fn(); + const videoRef = { current: document.createElement('video') } as React.RefObject; + + mockUseBarcodeScanner.mockReturnValue({ + videoRef, + result: { code: '12345', error: null }, + }); + + render(); + + expect(screen.getByText(/detected 12345/i)).toBeVisible(); + expect(onDetected).toHaveBeenCalledWith('12345'); + }); + + it('renders an error alert when scanning fails', () => { + const videoRef = { current: document.createElement('video') } as React.RefObject; + + mockUseBarcodeScanner.mockReturnValue({ + videoRef, + result: { code: '', error: 'Camera access denied' }, + }); + + render(); + + expect(screen.getByText(/camera access denied/i)).toBeVisible(); + }); +}); diff --git a/src/components/PickItemRow.test.tsx b/src/components/PickItemRow.test.tsx index cb3e2d3..7708048 100644 --- a/src/components/PickItemRow.test.tsx +++ b/src/components/PickItemRow.test.tsx @@ -145,4 +145,54 @@ describe('PickItemRow', () => { expect(screen.getByRole('dialog', { name: baseProduct.name })).toBeVisible(); }); + + it('uses inline controls on wide screens', async () => { + const onIncrement = vi.fn(); + const onDecrement = vi.fn(); + const onToggleCarton = vi.fn(); + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: /switch to carton packaging/i })); + await user.click(screen.getByRole('button', { name: /increase quantity/i })); + await user.click(screen.getByRole('button', { name: /decrease quantity/i })); + + expect(onToggleCarton).toHaveBeenCalledTimes(1); + expect(onIncrement).toHaveBeenCalledTimes(1); + expect(onDecrement).toHaveBeenCalledTimes(1); + }); + + it('opens controls from keyboard interaction on narrow screens', async () => { + mockMatchMedia(true); + const user = userEvent.setup(); + + render( + , + ); + + const row = screen.getByRole('button', { name: /test product/i }); + row.focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByRole('dialog', { name: baseProduct.name })).toBeVisible(); + }); }); diff --git a/src/components/ProductRow.test.tsx b/src/components/ProductRow.test.tsx index c0bee20..1d87807 100644 --- a/src/components/ProductRow.test.tsx +++ b/src/components/ProductRow.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { ProductRow } from './ProductRow'; import { Product } from '../models/Product'; @@ -32,4 +33,62 @@ describe('ProductRow', () => { expect(screen.getByText(/Barcode: 123456/)).toBeInTheDocument(); expect(screen.queryByText(/bottle/i)).not.toBeInTheDocument(); }); + + it('saves edits and closes the form', async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + + render( + , + ); + + await user.click(screen.getByLabelText(/edit sparkling water/i)); + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'Still Water'); + await user.click(screen.getByLabelText(/category/i)); + await user.selectOptions(screen.getByLabelText(/category/i), 'Snacks'); + await user.click(screen.getByLabelText(/save product/i)); + + expect(onSave).toHaveBeenCalledWith('prod-1', { + name: 'Still Water', + category: 'Snacks', + barcode: '123456', + }); + expect(screen.queryByLabelText(/save product/i)).not.toBeInTheDocument(); + }); + + it('surfaces validation errors from duplicate constraints', async () => { + const user = userEvent.setup(); + const onSave = vi.fn().mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateNameError' })) + .mockRejectedValueOnce(Object.assign(new Error('dup'), { name: 'DuplicateBarcodeError' })); + + render( + , + ); + + await user.click(screen.getByLabelText(/edit sparkling water/i)); + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'Sparkling Water'); + await user.click(screen.getByLabelText(/save product/i)); + + expect(await screen.findByText(/already exists/i)).toBeVisible(); + + await user.type(screen.getByLabelText(/barcode/i), '999'); + await user.click(screen.getByLabelText(/save product/i)); + + expect(await screen.findByText(/already assigned/i)).toBeVisible(); + }); + + it('allows clearing and scanning a new barcode', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByLabelText(/edit sparkling water/i)); + await user.click(screen.getByRole('button', { name: /clear/i })); + + expect(screen.getByRole('button', { name: /scan barcode/i })).toBeVisible(); + }); }); diff --git a/src/screens/ManageAreasScreen.test.tsx b/src/screens/ManageAreasScreen.test.tsx index d9689d1..e1ed354 100644 --- a/src/screens/ManageAreasScreen.test.tsx +++ b/src/screens/ManageAreasScreen.test.tsx @@ -11,13 +11,15 @@ vi.mock('../hooks/dataHooks', () => ({ })); const areaDeleteMock = vi.fn(); +const areaAddMock = vi.fn(); +const areaUpdateMock = vi.fn(); const pickListCountMock = vi.fn(); vi.mock('../context/DBProvider', () => ({ useDatabase: () => ({ areas: { - add: vi.fn(), - update: vi.fn(), + add: areaAddMock, + update: areaUpdateMock, delete: areaDeleteMock, }, pickLists: { @@ -48,4 +50,43 @@ describe('ManageAreasScreen deletion safeguards', () => { await screen.findByText(/cannot delete this area while 1 pick list\(s\) use it/i), ).toBeVisible(); }); + + it('adds a new area and clears the input', async () => { + pickListCountMock.mockResolvedValue(0); + const user = userEvent.setup(); + + render( + + + , + ); + + const nameField = screen.getByLabelText(/area name/i); + await user.type(nameField, 'Bakery'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(areaAddMock).toHaveBeenCalledTimes(1); + expect((areaAddMock.mock.calls[0][0] as { name: string }).name).toBe('Bakery'); + expect((nameField as HTMLInputElement).value).toBe(''); + }); + + it('updates an area name and shows feedback', async () => { + pickListCountMock.mockResolvedValue(0); + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: /edit front counter/i })); + const editField = screen.getByDisplayValue(/front counter/i); + await user.clear(editField); + await user.type(editField, 'Updated Front'); + await user.click(screen.getByRole('button', { name: /save area/i })); + + expect(areaUpdateMock).toHaveBeenCalledWith('area-1', expect.objectContaining({ name: 'Updated Front' })); + expect(await screen.findByText(/area updated/i)).toBeVisible(); + }); }); diff --git a/src/screens/ManageCategoriesScreen.test.tsx b/src/screens/ManageCategoriesScreen.test.tsx index ce7193f..631e772 100644 --- a/src/screens/ManageCategoriesScreen.test.tsx +++ b/src/screens/ManageCategoriesScreen.test.tsx @@ -22,18 +22,24 @@ vi.mock('../hooks/dataHooks', () => ({ })); const categoryDeleteMock = vi.fn(); +const categoryAddMock = vi.fn(); +const categoryUpdateMock = vi.fn(); +const productModifyMock = vi.fn(); vi.mock('../context/DBProvider', () => ({ useDatabase: () => ({ + transaction: async (_mode: string, _tableA: unknown, _tableB: unknown, callback: () => Promise) => { + await callback(); + }, categories: { - add: vi.fn(), - update: vi.fn(), + add: categoryAddMock, + update: categoryUpdateMock, delete: categoryDeleteMock, }, products: { where: () => ({ equals: () => ({ - modify: vi.fn(), + modify: productModifyMock, }), }), }, @@ -41,6 +47,13 @@ vi.mock('../context/DBProvider', () => ({ })); describe('ManageCategoriesScreen deletion safeguards', () => { + beforeEach(() => { + categoryAddMock.mockReset(); + categoryUpdateMock.mockReset(); + categoryDeleteMock.mockReset(); + productModifyMock.mockReset(); + }); + it('shows an error when trying to delete an in-use category', async () => { const user = userEvent.setup(); render( @@ -56,4 +69,58 @@ describe('ManageCategoriesScreen deletion safeguards', () => { await screen.findByText(/cannot delete 'snacks' while 1 product\(s\) use it/i), ).toBeVisible(); }); + + it('adds a category when a unique name is provided', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const nameField = screen.getByLabelText(/category name/i); + await user.clear(nameField); + await user.type(nameField, 'Drinks'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(categoryAddMock).toHaveBeenCalledTimes(1); + expect((nameField as HTMLInputElement).value).toBe(''); + expect(await screen.findByText(/category added/i)).toBeVisible(); + }); + + it('prevents adding a duplicate category name', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const nameField = screen.getByLabelText(/category name/i); + await user.clear(nameField); + await user.type(nameField, 'Snacks'); + await user.click(screen.getByRole('button', { name: /add/i })); + + expect(categoryAddMock).not.toHaveBeenCalled(); + expect(await screen.findByText(/already exists/i)).toBeVisible(); + }); + + it('updates a category and cascades the change to products', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: /edit snacks/i })); + const editField = screen.getByDisplayValue(/snacks/i); + await user.clear(editField); + await user.type(editField, 'Treats'); + await user.click(screen.getByRole('button', { name: /save category/i })); + + expect(categoryUpdateMock).toHaveBeenCalledWith('cat-1', expect.objectContaining({ name: 'Treats' })); + expect(productModifyMock).toHaveBeenCalled(); + expect(await screen.findByText(/category updated/i)).toBeVisible(); + }); }); diff --git a/src/screens/PickListsScreen.test.tsx b/src/screens/PickListsScreen.test.tsx index c8c90d0..890b6e7 100644 --- a/src/screens/PickListsScreen.test.tsx +++ b/src/screens/PickListsScreen.test.tsx @@ -1,5 +1,6 @@ import { MemoryRouter } from 'react-router-dom'; import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PickListsScreen } from './PickListsScreen'; @@ -15,6 +16,10 @@ let pickListsMock = [ { id: 'list-1', area_id: 'area-1', created_at: 5, categories: [], auto_add_new_products: false }, ]; +const pickListDeleteMock = vi.fn(); +const pickListUpdateMock = vi.fn(); +const pickItemDeleteMock = vi.fn(); + vi.mock('../hooks/dataHooks', () => ({ usePickLists: () => pickListsMock, useAreas: () => areasMock, @@ -24,12 +29,12 @@ vi.mock('../context/DBProvider', () => ({ useDatabase: () => ({ pickItems: { where: () => ({ - equals: () => ({ delete: vi.fn() }), + equals: () => ({ delete: pickItemDeleteMock }), }), }, pickLists: { - update: vi.fn(), - delete: vi.fn(), + update: pickListUpdateMock, + delete: pickListDeleteMock, where: () => ({ equals: () => ({ delete: vi.fn() }), }), @@ -39,6 +44,9 @@ vi.mock('../context/DBProvider', () => ({ describe('PickListsScreen sorting', () => { beforeEach(() => { + pickListDeleteMock.mockReset(); + pickListUpdateMock.mockReset(); + pickItemDeleteMock.mockReset(); areasMock = [ { id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }, { id: 'area-2', name: 'back room', created_at: 0, updated_at: 0 }, @@ -91,4 +99,44 @@ describe('PickListsScreen sorting', () => { expect(within(listItems[1]).getByText(/cafe/i)).toBeVisible(); expect(within(listItems[2]).getByText(/front counter/i)).toBeVisible(); }); + + it('opens the edit dialog and saves changes', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + + await user.click(screen.getAllByLabelText(/edit/i)[0]); + expect(screen.getByRole('dialog', { name: /edit pick list/i })).toBeVisible(); + + await user.selectOptions(screen.getByLabelText(/area/i), 'area-3'); + await user.clear(screen.getByLabelText(/notes/i)); + await user.type(screen.getByLabelText(/notes/i), 'Restock quickly'); + await user.click(screen.getByRole('button', { name: /save changes/i })); + + expect(pickListUpdateMock).toHaveBeenCalledWith('list-2', { + area_id: 'area-3', + notes: 'Restock quickly', + }); + expect(screen.queryByRole('dialog', { name: /edit pick list/i })).not.toBeInTheDocument(); + }); + + it('deletes a pick list when confirmed', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValueOnce(true); + + render( + + + , + ); + + await user.click(screen.getAllByLabelText(/delete/i)[0]); + + expect(pickItemDeleteMock).toHaveBeenCalled(); + expect(pickListDeleteMock).toHaveBeenCalledWith('list-2'); + }); });