diff --git a/.codex_playwright_version b/.codex_playwright_version index 43c989b..8b13789 100644 --- a/.codex_playwright_version +++ b/.codex_playwright_version @@ -1 +1 @@ -1.56.1 + diff --git a/.vscode/launch.json b/.vscode/launch.json index 175f3ee..f953a31 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -31,6 +31,13 @@ "request": "launch", "command": "npm run test:coverage", "cwd": "${workspaceFolder}" + }, + { + "name": "Lint and Build", + "type": "node-terminal", + "request": "launch", + "command": "npx tsc -noEmit && npm run build", + "cwd": "${workspaceFolder}" } ] } diff --git a/package-lock.json b/package-lock.json index 5dedfbe..46f8afd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", - "@types/uuid": "^11.0.0", "@zxing/browser": "^0.1.5", "date-fns": "^4.1.0", "dexie": "^4.2.1", @@ -2456,16 +2455,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", - "integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==", - "deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.", - "license": "MIT", - "dependencies": { - "uuid": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", diff --git a/package.json b/package.json index 8c803cf..75c2fc7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.5", "@mui/material": "^7.3.5", - "@types/uuid": "^11.0.0", "@zxing/browser": "^0.1.5", "date-fns": "^4.1.0", "dexie": "^4.2.1", diff --git a/src/components/AddProductDialog.tsx b/src/components/AddProductDialog.tsx index 4fc8ffd..d846eb8 100644 --- a/src/components/AddProductDialog.tsx +++ b/src/components/AddProductDialog.tsx @@ -266,11 +266,14 @@ export const AddProductDialog = ({ open={open} onClose={handleDialogClose} aria-label="Add product dialog" + aria-labelledby="add-product-dialog-title" + data-testid="add-product-dialog" fullWidth maxWidth="sm" PaperProps={{ role: 'form' }} + BackdropProps={{ 'data-testid': 'add-product-backdrop' }} > - + Add product - setScannerOpen(false)} aria-label="Scan barcode"> + setScannerOpen(false)} + aria-label="Scan barcode" + data-testid="scan-barcode-dialog" + BackdropProps={{ 'data-testid': 'scan-barcode-backdrop' }} + > Scan barcode PickItem[]>(); +const productsMock = vi.fn<() => Product[]>(); +const pickListMock = vi.fn(); + +// allow tests to mutate categories returned by the mocked hook +let categoriesVar: any[] = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }]; + +vi.mock('../hooks/dataHooks', () => ({ + usePickItems: () => pickItemsMock(), + useProducts: () => productsMock(), + usePickList: () => pickListMock(), + useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }], + useCategories: () => categoriesVar, +})); + +vi.mock('../context/DBProvider', () => ({ + useDatabase: () => ({ + pickItems: { + add: addMock, + update: updateMock, + get: vi.fn(async (id: string) => { + const items: PickItem[] = pickItemsMock() ?? []; + return items.find((it) => it.id === id); + }), + delete: deleteMock, + where: (_f: string) => ({ + equals: (val: any) => ({ + toArray: async () => { + const items: PickItem[] = pickItemsMock() ?? []; + return items.filter((it) => it.pick_list_id === val); + }, + count: async () => { + const items: PickItem[] = pickItemsMock() ?? []; + return items.filter((it) => it.pick_list_id === val).length; + }, + }), + }), + }, + }), +})); + +const defaultProducts: Product[] = [ + { + id: 'prod-1', + name: 'Cola', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'box', + barcode: '111', + archived: false, + created_at: 0, + updated_at: 0, + }, + { + id: 'prod-2', + name: 'Chips', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'box', + barcode: '222', + archived: false, + created_at: 0, + updated_at: 0, + }, +]; + +beforeEach(() => { + addMock.mockReset(); + updateMock.mockReset(); + deleteMock.mockReset(); + pickItemsMock.mockReset(); + productsMock.mockReset(); + pickListMock.mockReset(); + categoriesVar = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }]; + + productsMock.mockReturnValue(defaultProducts); + pickListMock.mockReturnValue({ + id: 'list-1', + area_id: 'area-1', + created_at: 0, + categories: ['cat-1'], + auto_add_new_products: false, + }); +}); + +describe('ActivePickListScreen - additional branches', () => { + const getProductInput = () => { + const byTestId = screen.queryByTestId('product-search-input'); + if (byTestId) return byTestId; + return screen.getByPlaceholderText('Search products'); + }; + + it('increments / decrements / toggles carton / toggles status / deletes an item', async () => { + pickItemsMock.mockReturnValue([ + { + id: 'item-1', + pick_list_id: 'list-1', + product_id: 'prod-1', + quantity: 1, + is_carton: false, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + ]); + + const user = userEvent.setup(); + render( + + + } /> + + , + ); + + const inc = screen.getByLabelText('Increase quantity'); + await user.click(inc); + expect(updateMock).toHaveBeenCalled(); + + const dec = screen.getByLabelText('Decrease quantity'); + await user.click(dec); + expect(updateMock).toHaveBeenCalled(); + + const cartonButton = screen.getByLabelText(/Switch to carton packaging/i); + await user.click(cartonButton); + expect(updateMock).toHaveBeenCalled(); + + const checkbox = screen.getByLabelText('Toggle picked status') as HTMLInputElement; + await user.click(checkbox); + await waitFor(() => { + expect(updateMock).toHaveBeenCalled(); + }); + + const deleteBtn = screen.getByLabelText('Delete item'); + await user.click(deleteBtn); + const confirmDelete = await screen.findByRole('button', { name: /delete/i }); + await user.click(confirmDelete); + expect(deleteMock).toHaveBeenCalledTimes(1); + }); + + it('handleMarkAllPicked updates items when items exist', async () => { + // normal case: itemState present -> mark all picked should update + pickItemsMock.mockReturnValue([ + { + id: 'item-2', + pick_list_id: 'list-1', + product_id: 'prod-2', + quantity: 1, + is_carton: false, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + ]); + + const user = userEvent.setup(); + render( + + + } /> + + , + ); + + const pickCompleteBtn = screen.getByRole('button', { name: /pick complete/i }); + await user.click(pickCompleteBtn); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalled(); + }); + }); + + it('packaging radios are disabled when unique packaging count === 1 and enabled when both present', async () => { + // single packaging type (all units) + pickItemsMock.mockReturnValue([ + { + id: 'item-3', + pick_list_id: 'list-1', + product_id: 'prod-1', + quantity: 1, + is_carton: false, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + ]); + + render( + + + } /> + + , + ); + + const unitsWrapper = screen.getByTestId('packaging-filter-units'); + const cartonsWrapper = screen.getByTestId('packaging-filter-cartons'); + const unitsInput = (unitsWrapper as HTMLElement).querySelector('input') as HTMLInputElement; + const cartonsInput = (cartonsWrapper as HTMLElement).querySelector('input') as HTMLInputElement; + expect(unitsInput.disabled).toBe(true); + expect(cartonsInput.disabled).toBe(true); + + // cleanup before re-rendering to avoid duplicate data-testid nodes + cleanup(); + + // both cartons and units present -> radios enabled + pickItemsMock.mockReturnValue([ + { + id: 'item-4', + pick_list_id: 'list-1', + product_id: 'prod-1', + quantity: 1, + is_carton: false, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + { + id: 'item-5', + pick_list_id: 'list-1', + product_id: 'prod-2', + quantity: 1, + is_carton: true, + status: 'pending', + created_at: 0, + updated_at: 0, + }, + ]); + + render( + + + } /> + + , + ); + + const unitsWrapper2 = screen.getByTestId('packaging-filter-units'); + const cartonsWrapper2 = screen.getByTestId('packaging-filter-cartons'); + const unitsInput2 = (unitsWrapper2 as HTMLElement).querySelector('input') as HTMLInputElement; + const cartonsInput2 = (cartonsWrapper2 as HTMLElement).querySelector('input') as HTMLInputElement; + expect(unitsInput2.disabled).toBe(false); + expect(cartonsInput2.disabled).toBe(false); + }); + + it('categoryOptions resolves legacy names to ids when pickList.categories contains names', () => { + // Set categoriesVar so useCategories returns an id `c1` for 'Drinks' + categoriesVar = [{ id: 'c1', name: 'Drinks', created_at: 0, updated_at: 0 }]; + + // pickList with legacy name 'Drinks' + pickListMock.mockReturnValue({ + id: 'list-1', + area_id: 'area-1', + created_at: 0, + categories: ['Drinks'], + auto_add_new_products: false, + }); + + render( + + + } /> + + , + ); + + const select = screen.getByLabelText(/Filter by category/i) as HTMLSelectElement; + const option = Array.from(select.options).find((o) => o.value === 'c1'); + expect(option).toBeDefined(); + expect(option?.text).toMatch(/Drinks/i); + }); +}); diff --git a/src/screens/ManageProductsScreen.additional.test.tsx b/src/screens/ManageProductsScreen.additional.test.tsx new file mode 100644 index 0000000..27ce4f3 --- /dev/null +++ b/src/screens/ManageProductsScreen.additional.test.tsx @@ -0,0 +1,130 @@ +// src/screens/ManageProductsScreen.additional.test.tsx +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ManageProductsScreen from './ManageProductsScreen'; + +const mockUseProducts = vi.fn(); +const mockUseCategories = vi.fn(); + +vi.mock('../hooks/dataHooks', () => ({ + useProducts: () => mockUseProducts(), + useCategories: () => mockUseCategories(), +})); + +// DB mock +const productsDb = { + get: vi.fn(), + put: vi.fn(), + where: vi.fn(), // must return { equals: () => ({ first: async () => ... }) } + filter: vi.fn(), +}; +const categoriesDb = { + where: vi.fn(), + get: vi.fn(), + add: vi.fn(), +}; +const pickListsDb = { + toArray: vi.fn(), +}; +const pickItemsDb = { + add: vi.fn(), +}; +const mockDb: any = { + products: productsDb, + categories: categoriesDb, + pickLists: pickListsDb, + pickItems: pickItemsDb, + transaction: vi.fn(async (_mode: string, ...args: any[]) => { + const cb = args[args.length - 1]; + if (typeof cb === 'function') return cb(); + return undefined; + }), +}; + +vi.mock('../context/DBProvider', () => ({ + useDatabase: () => mockDb, +})); + +vi.mock('uuid', () => ({ v4: () => 'new-product-id' })); + +beforeEach(() => { + mockUseProducts.mockReset(); + mockUseCategories.mockReset(); + Object.values(productsDb).forEach((f) => typeof f === 'function' && (f as any).mockReset && (f as any).mockReset()); + Object.values(categoriesDb).forEach((f) => typeof f === 'function' && (f as any).mockReset && (f as any).mockReset()); + pickListsDb.toArray.mockReset(); + pickItemsDb.add.mockReset(); + mockDb.transaction.mockReset(); + + // Default product DB stubs + productsDb.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) })); + productsDb.filter.mockImplementation(() => ({ delete: vi.fn() })); +}); + +describe('ManageProductsScreen update product category creation branch', () => { + it('creates a new category when updates.category is an unknown name and saves product', async () => { + const existingProduct = { + id: 'prod-2', + name: 'Another Product', + category: 'cat-old', + barcode: '654321', + unit_type: 'unit', + bulk_name: 'pack', + archived: false, + created_at: 0, + updated_at: 0, + }; + + mockUseProducts.mockReturnValue([existingProduct]); + mockUseCategories.mockReturnValue([]); // no categories available + + productsDb.get.mockResolvedValue(existingProduct); + + // products.where('barcode').equals(value).first() must exist for uniqueness check + productsDb.where.mockImplementation((field?: string) => ({ + equals: (value?: string) => ({ + first: async () => undefined, + }), + })); + + // categories.where('name').equals(...).first => undefined (no matching category name) + categoriesDb.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) })); + categoriesDb.get.mockResolvedValue(undefined); // isExistingId check -> undefined + categoriesDb.add.mockResolvedValue('new-cat-id'); + + productsDb.put.mockResolvedValue('prod-2'); + productsDb.filter.mockImplementation(() => ({ delete: vi.fn() })); + + const user = userEvent.setup(); + render( + + + , + ); + + // Open edit for the product + await user.click(screen.getByLabelText(/edit another product/i)); + + // Find the category field corresponding to that row (value should be 'cat-old') + const categoryInputs = screen.getAllByLabelText(/category/i); + const categoryField = categoryInputs.find((i) => (i as HTMLInputElement).value === 'cat-old') as HTMLInputElement; + expect(categoryField).toBeDefined(); + + // Change category to a new name that doesn't exist + fireEvent.change(categoryField, { target: { value: 'New Category Name' } }); + + // Click product-row Save button + const saveButtons = screen.getAllByRole('button', { name: /save product/i }); + const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0]; + expect(productSaveButton).toBeDefined(); + await user.click(productSaveButton as HTMLElement); + + // Wait for categories.add to be called (new category created) and product saved + await waitFor(() => { + expect(categoriesDb.add).toHaveBeenCalled(); + expect(productsDb.put).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/ManageProductsScreen.test.tsx b/src/screens/ManageProductsScreen.test.tsx index c685ebf..bef1aa9 100644 --- a/src/screens/ManageProductsScreen.test.tsx +++ b/src/screens/ManageProductsScreen.test.tsx @@ -251,16 +251,31 @@ describe('ManageProductsScreen barcode lookup', () => { , ); + // open dialog and wait for it to be present await openAddProductDialog(user); + await screen.findByTestId('add-product-dialog'); - await user.type(screen.getByLabelText(/name/i), 'existing product'); + // click the close icon and wait for dialog to be removed + await user.click(screen.getByRole('button', { name: /close add product/i })); + await waitFor(() => { + expect(screen.queryByTestId('add-product-dialog')).not.toBeInTheDocument(); + }, { timeout: 2000 }); - const saveBtn = findSaveButton(); - expect(saveBtn).toBeTruthy(); - await user.click(saveBtn as HTMLElement); + // reopen the dialog and wait for it to appear + await openAddProductDialog(user); + const dialog = await screen.findByTestId('add-product-dialog', {}, { timeout: 2000 }); + expect(dialog).toBeInTheDocument(); + + // find the MUI backdrop by test-id and click it + const backdrop = await screen.findByTestId('add-product-backdrop', {}, { timeout: 2000 }); + expect(backdrop).toBeTruthy(); + await user.click(backdrop); + + // finally wait for dialog to be removed + await waitFor(() => { + expect(screen.queryByTestId('add-product-dialog')).not.toBeInTheDocument(); + }, { timeout: 2000 }); - expect(await screen.findByText(/product with this name already exists/i)).toBeVisible(); - expect(mockDb.products.add).not.toHaveBeenCalled(); }); it('informs the user when barcode lookup happens offline', async () => {