From 5bb16d155fc90dfdead7bed01253566984b2f84a Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 1 Dec 2025 21:23:45 +1000 Subject: [PATCH] Add tests and utilities for coverage improvements --- .codex_playwright_version | 2 +- README.md | 11 ++ src/components/EditableEntityList.tsx | 27 +--- src/components/ProductRow.tsx | 14 +- .../__tests__/AddProductDialog.test.tsx | 152 ++++++++++++++++++ .../__tests__/EditableEntityList.test.tsx | 97 +++++++++++ .../__tests__/NumericStepper.test.tsx | 49 +++++- .../__tests__/ProductAutocomplete.test.tsx | 115 +++++++++++++ .../__tests__/ProductRow.errors.test.tsx | 103 ++++++++++++ src/screens/ActivePickListScreen.tsx | 40 +---- ...anageProductsScreen.updateProduct.test.tsx | 144 +++++++++++++++++ src/test/makeNamedError.ts | 5 + .../__tests__/activePickListUtils.test.ts | 66 ++++++++ src/utils/activePickListUtils.ts | 40 +++++ src/utils/editableEntityUtils.ts | 25 +++ src/utils/productRowUtils.ts | 16 ++ vite.config.ts | 10 +- 17 files changed, 840 insertions(+), 76 deletions(-) create mode 100644 README.md create mode 100644 src/components/__tests__/AddProductDialog.test.tsx create mode 100644 src/components/__tests__/EditableEntityList.test.tsx create mode 100644 src/components/__tests__/ProductAutocomplete.test.tsx create mode 100644 src/components/__tests__/ProductRow.errors.test.tsx create mode 100644 src/screens/__tests__/ManageProductsScreen.updateProduct.test.tsx create mode 100644 src/test/makeNamedError.ts create mode 100644 src/utils/__tests__/activePickListUtils.test.ts create mode 100644 src/utils/activePickListUtils.ts create mode 100644 src/utils/editableEntityUtils.ts create mode 100644 src/utils/productRowUtils.ts diff --git a/.codex_playwright_version b/.codex_playwright_version index 8b13789..373aea9 100644 --- a/.codex_playwright_version +++ b/.codex_playwright_version @@ -1 +1 @@ - +1.57.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..573a89e --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# StockFill + +## Testing and coverage + +The test suite uses Vitest with React Testing Library. Coverage thresholds are enforced via the Vite test configuration to keep statements, branches, functions, and lines at or above 80%. Run tests locally with: + +``` +npm run test:coverage +``` + +The suite includes unit coverage for product management flows, pick list utilities, numeric steppers, autocompletion, and dialog behaviors to ensure core UX remains stable. diff --git a/src/components/EditableEntityList.tsx b/src/components/EditableEntityList.tsx index 582a125..72ab8a8 100644 --- a/src/components/EditableEntityList.tsx +++ b/src/components/EditableEntityList.tsx @@ -14,6 +14,8 @@ import { TextField, } from '@mui/material'; import { useMemo, useState } from 'react'; +import { ActionOutcome, applyOutcome, FeedbackState } from '../utils/editableEntityUtils'; +export type { ActionOutcome, FeedbackState } from '../utils/editableEntityUtils'; export interface EditableEntity { id: string; @@ -21,12 +23,6 @@ export interface EditableEntity { secondaryText?: string; } -export interface ActionOutcome { - text?: string; - severity?: AlertColor; - success?: boolean; -} - interface EditableEntityListProps { nameLabel: string; addButtonLabel?: string; @@ -39,11 +35,6 @@ interface EditableEntityListProps { onDelete: (id: string, name: string) => Promise; } -interface FeedbackState { - text: string; - severity: AlertColor; -} - export const EditableEntityList = ({ nameLabel, addButtonLabel = 'Add', @@ -69,19 +60,11 @@ export const EditableEntityList = ({ }; }, [validateName]); - const applyOutcome = (outcome: ActionOutcome | void, defaultText: string, defaultSeverity: AlertColor = 'success') => { - const success = outcome?.success ?? outcome?.severity !== 'error'; - const text = outcome?.text ?? defaultText; - const severity = outcome?.severity ?? defaultSeverity; - setFeedback({ text, severity }); - return success; - }; - const handleAdd = async () => { const trimmed = newName.trim(); if (!isNameValid(newName, null)) return; try { - const success = applyOutcome(await onAdd(trimmed), `${entityLabel} added.`); + const success = applyOutcome(setFeedback, await onAdd(trimmed), `${entityLabel} added.`); if (success) { setNewName(''); } @@ -107,7 +90,7 @@ export const EditableEntityList = ({ if (!isNameValid(editName, editingId)) return; try { - const success = applyOutcome(await onUpdate(editingId, trimmed), `${entityLabel} updated.`); + const success = applyOutcome(setFeedback, await onUpdate(editingId, trimmed), `${entityLabel} updated.`); if (success) { cancelEditing(); } @@ -118,7 +101,7 @@ export const EditableEntityList = ({ const handleDelete = async (id: string, name: string) => { try { - const success = applyOutcome(await onDelete(id, name), `${entityLabel} deleted.`); + const success = applyOutcome(setFeedback, await onDelete(id, name), `${entityLabel} deleted.`); if (success && editingId === id) { cancelEditing(); } diff --git a/src/components/ProductRow.tsx b/src/components/ProductRow.tsx index 45283e1..78246dc 100644 --- a/src/components/ProductRow.tsx +++ b/src/components/ProductRow.tsx @@ -18,6 +18,7 @@ import CloseIcon from '@mui/icons-material/Close'; import { ChangeEvent, useEffect, useState } from 'react'; import { Product } from '../models/Product'; import { BarcodeScannerView } from './BarcodeScannerView'; +import { getInitialFormState, ProductFormState } from '../utils/productRowUtils'; interface ProductRowProps { product: Product; @@ -36,19 +37,6 @@ interface ProductRowProps { onDelete: (productId: string) => Promise | void; } -interface ProductFormState { - name: string; - category: string; - barcode: string; -} - -const getInitialFormState = (product: Product, categoriesById: Map): ProductFormState => ({ - name: product.name, - // If product.category is an id, resolve to name; otherwise assume it is already a name - category: categoriesById.get(product.category) ?? product.category ?? '', - barcode: product.barcode ?? '', -}); - export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => { const [isEditing, setIsEditing] = useState(false); const [formState, setFormState] = useState(() => getInitialFormState(product, categoriesById)); diff --git a/src/components/__tests__/AddProductDialog.test.tsx b/src/components/__tests__/AddProductDialog.test.tsx new file mode 100644 index 0000000..69db5de --- /dev/null +++ b/src/components/__tests__/AddProductDialog.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { AddProductDialog } from '../AddProductDialog'; +import { createMockDb } from '../../testUtils/mockDb'; +import type { Product } from '../../models/Product'; + +let mockDb = createMockDb(); +const mockUseProducts = vi.fn(); +const mockFetchProductFromOFF = vi.fn(); + +vi.mock('../../context/DBProvider', () => ({ + useDatabase: () => mockDb, +})); + +vi.mock('../../hooks/dataHooks', () => ({ + useProducts: () => mockUseProducts(), +})); + +vi.mock('../BarcodeScannerView', () => ({ + BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => ( + + ), +})); + +vi.mock('../modules/openFoodFacts', () => ({ + fetchProductFromOFF: (...args: unknown[]) => mockFetchProductFromOFF(...args), +})); + +const defaultCategories = ['Fresh', 'Pantry']; + +describe('AddProductDialog', () => { + beforeEach(() => { + mockDb = createMockDb(); + mockUseProducts.mockReturnValue([]); + mockFetchProductFromOFF.mockReset(); + }); + + it('shows offline alert when barcode lookup attempted offline', async () => { + vi.spyOn(window.navigator, 'onLine', 'get').mockReturnValue(false); + + render( + , + ); + + await waitFor(() => expect(screen.getByTestId('barcode-offline')).toBeInTheDocument()); + expect(screen.getByTestId('product-barcode-input')).toHaveValue('12345'); + }); + + it('applies initialBarcode and triggers lookup', async () => { + mockFetchProductFromOFF.mockResolvedValue({ name: 'From OFF' }); + + render( + , + ); + + await waitFor(() => expect(mockFetchProductFromOFF).toHaveBeenCalledWith('999')); + expect(screen.getByTestId('product-barcode-input')).toHaveValue('999'); + await waitFor(() => expect(screen.getByLabelText(/name/i)).toHaveValue('From OFF')); + }); + + it('sets helper text for duplicate name and duplicate barcode errors', async () => { + const existing: Product = { + id: 'existing', + name: 'Existing', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 0, + barcode: 'dupe', + }; + mockUseProducts.mockReturnValue([existing]); + + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText(/name/i), 'Existing'); + await user.type(screen.getByLabelText(/barcode/i), 'unique'); + await user.click(screen.getByRole('button', { name: /save product/i })); + + await waitFor(() => expect(screen.getByText(/a product with this name already exists/i)).toBeInTheDocument()); + + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'Different'); + await user.clear(screen.getByLabelText(/barcode/i)); + await user.type(screen.getByLabelText(/barcode/i), 'dupe'); + await user.click(screen.getByRole('button', { name: /save product/i })); + + await waitFor(() => + expect(screen.getByText(/this barcode is already assigned to another product/i)).toBeInTheDocument(), + ); + }); + + it('adds product to eligible pick lists', async () => { + const timestamp = Date.now(); + mockDb = createMockDb({ + pickLists: [ + { + id: 'list-1', + area_id: 'a', + created_at: timestamp, + completed_at: undefined, + notes: 'Auto', + categories: ['Fresh'], + auto_add_new_products: true, + }, + { + id: 'list-2', + area_id: 'b', + created_at: timestamp, + completed_at: undefined, + notes: 'Skip', + categories: ['Pantry'], + auto_add_new_products: false, + }, + ], + categories: [ + { id: 'fresh-id', name: 'Fresh', created_at: timestamp, updated_at: timestamp }, + { id: 'pantry-id', name: 'Pantry', created_at: timestamp, updated_at: timestamp }, + ], + }); + const user = userEvent.setup(); + + render( + , + ); + + await user.type(screen.getByLabelText(/name/i), 'Lettuce'); + await user.click(screen.getByRole('button', { name: /save product/i })); + + await waitFor(() => { + expect(mockDb.pickItems.items.some((item) => item.pick_list_id === 'list-1')).toBe(true); + expect(mockDb.pickItems.items.some((item) => item.pick_list_id === 'list-2')).toBe(false); + }); + }); +}); diff --git a/src/components/__tests__/EditableEntityList.test.tsx b/src/components/__tests__/EditableEntityList.test.tsx new file mode 100644 index 0000000..0a19c08 --- /dev/null +++ b/src/components/__tests__/EditableEntityList.test.tsx @@ -0,0 +1,97 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import EditableEntityList from '../EditableEntityList'; +import { makeNamedError } from '../../test/makeNamedError'; + +const entities = [ + { id: '1', name: 'First' }, + { id: '2', name: 'Second' }, +]; + +describe('EditableEntityList', () => { + it('disables add button for blank names and when validateName fails', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + + render( + value !== 'Invalid'} + />, + ); + + const addButton = screen.getByRole('button', { name: /add/i }); + expect(addButton).toBeDisabled(); + + await user.type(screen.getByLabelText(/name/i), ' '); + expect(addButton).toBeDisabled(); + + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'Invalid'); + expect(addButton).toBeDisabled(); + expect(onAdd).not.toHaveBeenCalled(); + }); + + it('shows feedback for failed add and update operations', async () => { + const user = userEvent.setup(); + const onAdd = vi.fn().mockResolvedValue({ success: false, text: 'x', severity: 'error' }); + const onUpdate = vi.fn().mockRejectedValue(makeNamedError('Unexpected')); + + render( + , + ); + + await user.type(screen.getByLabelText(/name/i), 'New One'); + await user.click(screen.getByRole('button', { name: /add/i })); + + await waitFor(() => expect(screen.getByText('x')).toBeInTheDocument()); + + await user.click(screen.getByLabelText(/edit first/i)); + await user.clear(screen.getByDisplayValue('First')); + await user.type(screen.getByDisplayValue('First'), 'Updated'); + await user.click(screen.getByLabelText(/save item/i)); + + await waitFor(() => + expect(screen.getByText(/unable to update item/i)).toBeInTheDocument(), + ); + }); + + it('supports editing cancel/save flows and delete feedback', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn().mockResolvedValue({ text: 'Deleted', severity: 'success' }); + const onUpdate = vi.fn().mockResolvedValue({ text: 'Saved', severity: 'success' }); + + render( + , + ); + + await user.click(screen.getByLabelText(/edit second/i)); + const editInput = screen.getByDisplayValue('Second'); + await user.clear(editInput); + await user.type(editInput, 'Second Updated'); + await user.click(screen.getByLabelText(/save item/i)); + + await waitFor(() => expect(onUpdate).toHaveBeenCalled()); + + await user.click(screen.getByLabelText(/delete second updated/i)); + await waitFor(() => expect(onDelete).toHaveBeenCalledWith('2', 'Second Updated')); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + }); +}); diff --git a/src/components/__tests__/NumericStepper.test.tsx b/src/components/__tests__/NumericStepper.test.tsx index 04e99e1..ff864e0 100644 --- a/src/components/__tests__/NumericStepper.test.tsx +++ b/src/components/__tests__/NumericStepper.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { useState } from 'react'; import { NumericStepper } from '../NumericStepper'; @@ -54,4 +54,51 @@ describe('NumericStepper', () => { expect(screen.getByRole('button', { name: 'decrease Items' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'increase Items' })).toBeInTheDocument(); }); + + it('calls onChange with the minimum when decrease clicked at lower bound', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /decrease qty/i })); + + expect(handleChange).toHaveBeenCalledWith(1); + }); + + it('calls onChange with incremented value when increase clicked', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /increase qty/i })); + + expect(handleChange).toHaveBeenCalledWith(3); + }); + + it('parses typed value and calls onChange with numeric value', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + const Wrapper = () => { + const [value, setValue] = useState(2); + return ( + { + handleChange(val); + setValue(val); + }} + /> + ); + }; + + render(); + + const input = screen.getByRole('spinbutton', { name: /qty/i }); + await user.clear(input); + await user.type(input, '7'); + + expect(handleChange).toHaveBeenLastCalledWith(7); + }); }); diff --git a/src/components/__tests__/ProductAutocomplete.test.tsx b/src/components/__tests__/ProductAutocomplete.test.tsx new file mode 100644 index 0000000..4d426de --- /dev/null +++ b/src/components/__tests__/ProductAutocomplete.test.tsx @@ -0,0 +1,115 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ProductAutocomplete } from '../ProductAutocomplete'; +import { Product } from '../../models/Product'; + +const mockUseCategories = vi.fn(); + +vi.mock('../../hooks/dataHooks', () => ({ + useCategories: () => mockUseCategories(), +})); + +const products: Product[] = [ + { + id: 'p1', + name: 'Apple', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 0, + }, + { + id: 'p2', + name: 'Banana', + category: 'cat-2', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 0, + }, +]; + +describe('ProductAutocomplete', () => { + beforeEach(() => { + mockUseCategories.mockReturnValue([ + { id: 'cat-1', name: 'Fruit', created_at: 0, updated_at: 0 }, + { id: 'cat-2', name: 'Pantry', created_at: 0, updated_at: 0 }, + ]); + }); + + it('calls onQueryChange on input change', async () => { + const user = userEvent.setup(); + const onQueryChange = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('product-search-input'); + await user.type(input, 'App'); + + expect(onQueryChange).toHaveBeenCalledWith('App'); + expect((input as HTMLInputElement).value).toBe('App'); + }); + + it('clears query on clear reason and calls onQueryChange with empty string', async () => { + const user = userEvent.setup(); + const onQueryChange = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('product-search-input'); + await user.type(input, 'Ban'); + const clearButton = await screen.findByLabelText('Clear'); + await user.click(clearButton); + + await waitFor(() => { + expect(onQueryChange).toHaveBeenCalledWith(''); + expect((input as HTMLInputElement).value).toBe(''); + }); + }); + + it('calls onSelect and clears selection when product chosen', async () => { + const user = userEvent.setup(); + const onSelect = vi.fn(); + + render( + , + ); + + const input = screen.getByTestId('product-search-input'); + await user.click(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => expect(onSelect).toHaveBeenCalledWith(products[0])); + expect((input as HTMLInputElement).value).toBe(''); + }); + + it('disables add button when handler missing', () => { + render(); + const addButton = screen.getByLabelText(/add product/i); + expect(addButton).toBeDisabled(); + + render( + , { + container: document.body.appendChild(document.createElement('div')), + }, + ); + expect(screen.getAllByLabelText(/add product/i).at(-1)).not.toBeDisabled(); + }); +}); diff --git a/src/components/__tests__/ProductRow.errors.test.tsx b/src/components/__tests__/ProductRow.errors.test.tsx new file mode 100644 index 0000000..8bf906f --- /dev/null +++ b/src/components/__tests__/ProductRow.errors.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ProductRow } from '../ProductRow'; +import { makeNamedError } from '../../test/makeNamedError'; + +vi.mock('../BarcodeScannerView', () => ({ + BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => ( + + ), +})); + +const categories = ['Snacks', 'Drinks']; +const categoriesById = new Map([ + ['cat-1', 'Snacks'], + ['cat-2', 'Drinks'], +]); + +const baseProduct = { + id: 'p1', + name: 'Product One', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 0, +}; + +describe('ProductRow error handling', () => { + let onSave: ReturnType; + let onDelete: ReturnType; + + beforeEach(() => { + onSave = vi.fn(); + onDelete = vi.fn(); + }); + + const renderRow = (productOverrides: Partial = {}) => + render( + , + ); + + it('shows helper text when onSave throws DuplicateNameError', async () => { + const user = userEvent.setup(); + onSave.mockRejectedValue(makeNamedError('DuplicateNameError')); + + renderRow(); + await user.click(screen.getByLabelText(/edit product one/i)); + await user.clear(screen.getByLabelText(/name/i)); + await user.type(screen.getByLabelText(/name/i), 'Updated'); + await user.click(screen.getByLabelText(/save product/i)); + + await waitFor(() => { + expect(screen.getByText('A product with this name already exists.')).toBeInTheDocument(); + }); + }); + + it('shows barcode helper text when onSave throws DuplicateBarcodeError', async () => { + const user = userEvent.setup(); + onSave.mockRejectedValue(makeNamedError('DuplicateBarcodeError')); + + renderRow({ barcode: '123' }); + await user.click(screen.getByLabelText(/edit product one/i)); + await user.type(screen.getByLabelText(/barcode/i), '456'); + await user.click(screen.getByLabelText(/save product/i)); + + await waitFor(() => { + expect(screen.getByText('This barcode is already assigned to another product.')).toBeInTheDocument(); + }); + }); + + it('clears barcode input and toggles scanner dialog visibility', async () => { + const user = userEvent.setup(); + + renderRow({ barcode: 'abc-123' }); + await user.click(screen.getByLabelText(/edit product one/i)); + + const barcodeField = screen.getByLabelText(/barcode/i); + expect(barcodeField).toHaveValue('abc-123'); + + await user.click(screen.getByRole('button', { name: /clear/i })); + expect(barcodeField).toHaveValue(''); + expect(screen.queryByText(/barcode/i, { selector: '.MuiFormHelperText-root' })).not.toBeInTheDocument(); + + // When barcode is empty, scanner button is shown and opens dialog + await user.click(screen.getByLabelText(/cancel edit/i)); + await user.click(screen.getByLabelText(/edit product one/i)); + await waitFor(() => expect(screen.getByRole('button', { name: /scan barcode/i })).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: /scan barcode/i })); + expect(screen.getByRole('dialog', { name: /scan barcode/i })).toBeInTheDocument(); + await user.click(screen.getByText(/mock scan/i)); + await waitFor(() => expect(screen.queryByRole('dialog', { name: /scan barcode/i })).not.toBeInTheDocument()); + }); +}); diff --git a/src/screens/ActivePickListScreen.tsx b/src/screens/ActivePickListScreen.tsx index a7b7d6f..25f51eb 100644 --- a/src/screens/ActivePickListScreen.tsx +++ b/src/screens/ActivePickListScreen.tsx @@ -33,8 +33,7 @@ import { PickItem } from '../models/PickItem'; import { Product } from '../models/Product'; import { ProductAutocomplete } from '../components/ProductAutocomplete'; import { AddProductDialog } from '../components/AddProductDialog'; - -const normalizeName = (name: string) => name.trim().toLowerCase(); +import { dedupeByIdThenNameAndSort, normalizeName } from '../utils/activePickListUtils'; const ActivePickListScreen = () => { const { id } = useParams(); @@ -117,42 +116,7 @@ const ActivePickListScreen = () => { [areas, pickList?.area_id], ); - const sortedProducts = useMemo(() => { - const dedupedById = new Map(); - - products.forEach((product: Product) => { - const existing = dedupedById.get(product.id); - if (!existing || product.updated_at > existing.updated_at) { - dedupedById.set(product.id, product); - } - }); - - const dedupedByName = new Map(); - - dedupedById.forEach((product: Product) => { - const normalizedName = product.name.trim().toLowerCase(); - const existing = dedupedByName.get(normalizedName); - - if (!existing || product.updated_at > existing.updated_at) { - dedupedByName.set(normalizedName, product); - } - }); - - return Array.from(dedupedByName.values()).sort((a, b) => { - const normalizedNameA = normalizeName(a.name); - const normalizedNameB = normalizeName(b.name); - - const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, { - sensitivity: 'base', - }); - - if (nameComparison !== 0) { - return nameComparison; - } - - return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); - }); - }, [products]); + const sortedProducts = useMemo(() => dedupeByIdThenNameAndSort(products), [products]); // Build a category id -> name map for display and name -> id map for resolution const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]); diff --git a/src/screens/__tests__/ManageProductsScreen.updateProduct.test.tsx b/src/screens/__tests__/ManageProductsScreen.updateProduct.test.tsx new file mode 100644 index 0000000..3683048 --- /dev/null +++ b/src/screens/__tests__/ManageProductsScreen.updateProduct.test.tsx @@ -0,0 +1,144 @@ +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import ManageProductsScreen from '../ManageProductsScreen'; +import { createMockDb } from '../../testUtils/mockDb'; + +let mockDb = createMockDb(); +const mockUseProducts = vi.fn(); +const mockUseCategories = vi.fn(); + +vi.mock('../../hooks/dataHooks', () => ({ + useProducts: () => mockUseProducts(), + useCategories: () => mockUseCategories(), +})); + +vi.mock('../../context/DBProvider', () => ({ + useDatabase: () => mockDb, +})); + +const clickSave = async (user: ReturnType) => { + const saveButton = screen + .getAllByRole('button') + .find((btn) => /save product/i.test(btn.getAttribute('aria-label') || btn.textContent || '')); + if (!saveButton) throw new Error('Save button not found'); + await user.click(saveButton); +}; + +describe('ManageProductsScreen updateProduct', () => { + beforeEach(() => { + const timestamp = Date.now(); + mockDb = createMockDb({ + products: [ + { + id: 'p1', + name: 'First', + category: 'cat-1', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: timestamp, + updated_at: timestamp, + }, + { + id: 'p2', + name: 'Second', + category: 'cat-2', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: timestamp, + updated_at: timestamp, + }, + ], + categories: [ + { id: 'cat-1', name: 'Cat One', created_at: timestamp, updated_at: timestamp }, + { id: 'cat-2', name: 'Cat Two', created_at: timestamp, updated_at: timestamp }, + ], + }); + mockUseProducts.mockReturnValue(mockDb.products.items); + mockUseCategories.mockReturnValue(mockDb.categories.items); + }); + + it('rethrows DuplicateNameError for ProductRow to handle', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + await user.click(screen.getByLabelText(/edit second/i)); + await user.clear(screen.getByLabelText(/^name$/i)); + await user.type(screen.getByLabelText(/^name$/i), 'First'); + await clickSave(user); + + await waitFor(() => + expect( + screen.getByText('A product with this name already exists.'), + ).toBeInTheDocument(), + ); + }); + + it('rethrows DuplicateBarcodeError for ProductRow to handle', async () => { + const user = userEvent.setup(); + mockDb.products.items[0].barcode = 'dup'; + mockUseProducts.mockReturnValue([...mockDb.products.items]); + + render( + + + , + ); + + await user.click(screen.getByLabelText(/edit second/i)); + await user.type(screen.getByLabelText(/barcode/i), 'dup'); + await clickSave(user); + + await waitFor(() => + expect( + screen.getByText('This barcode is already assigned to another product.'), + ).toBeInTheDocument(), + ); + }); + + it('creates category when missing and saves product with new category id', async () => { + const user = userEvent.setup(); + const timestamp = Date.now(); + mockDb = createMockDb({ + products: [ + { + id: 'p3', + name: 'Needs Category', + category: 'Untracked', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: timestamp, + updated_at: timestamp, + }, + ], + categories: [], + }); + mockUseProducts.mockReturnValue(mockDb.products.items); + mockUseCategories.mockReturnValue([]); + + render( + + + , + ); + + await user.click(screen.getByLabelText(/edit needs category/i)); + await user.type(screen.getByLabelText(/^name$/i), '!'); + await clickSave(user); + + await waitFor(() => { + expect(mockDb.categories.items.some((c) => c.name === 'Untracked')).toBe(true); + const updated = mockDb.products.items.find((p) => p.id === 'p3'); + const categoryId = mockDb.categories.items.find((c) => c.name === 'Untracked')?.id; + expect(updated?.category).toBe(categoryId); + }); + }); +}); diff --git a/src/test/makeNamedError.ts b/src/test/makeNamedError.ts new file mode 100644 index 0000000..80f419f --- /dev/null +++ b/src/test/makeNamedError.ts @@ -0,0 +1,5 @@ +export function makeNamedError(name: string, message?: string) { + const error = new Error(message ?? name); + (error as any).name = name; + return error; +} diff --git a/src/utils/__tests__/activePickListUtils.test.ts b/src/utils/__tests__/activePickListUtils.test.ts new file mode 100644 index 0000000..ee2ea04 --- /dev/null +++ b/src/utils/__tests__/activePickListUtils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { dedupeByIdThenNameAndSort, normalizeName } from '../activePickListUtils'; +import { Product } from '../../models/Product'; + +describe('activePickListUtils', () => { + it('normalizes name by trimming and lowercasing', () => { + expect(normalizeName(' Hello ')).toBe('hello'); + }); + + it('dedupes by id, then name, and sorts', () => { + const products: Product[] = [ + { + id: '1', + name: 'Banana', + category: 'c', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 1, + }, + { + id: '1', + name: 'Banana newer', + category: 'c', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 2, + }, + { + id: '2', + name: ' apple', + category: 'c', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 1, + }, + { + id: '3', + name: 'Apple ', + category: 'c', + unit_type: 'unit', + bulk_name: 'carton', + archived: false, + created_at: 0, + updated_at: 3, + }, + ]; + + const result = dedupeByIdThenNameAndSort(products); + + expect(result).toHaveLength(2); + expect(result.some((p) => p.name === 'Banana newer')).toBe(true); + expect(result.find((p) => normalizeName(p.name) === 'apple')?.updated_at).toBe(3); + expect(result[0].name.toLowerCase().trim()).toBe('apple'); + expect(result[1].name.toLowerCase().includes('banana')).toBe(true); + }); + + it('returns empty array for no products', () => { + expect(dedupeByIdThenNameAndSort([])).toEqual([]); + }); +}); diff --git a/src/utils/activePickListUtils.ts b/src/utils/activePickListUtils.ts new file mode 100644 index 0000000..fc2692c --- /dev/null +++ b/src/utils/activePickListUtils.ts @@ -0,0 +1,40 @@ +import { Product } from '../models/Product'; + +export const normalizeName = (name: string): string => name.trim().toLowerCase(); + +export const dedupeByIdThenNameAndSort = (products: Product[]): Product[] => { + const dedupedById = new Map(); + + products.forEach((product) => { + const existing = dedupedById.get(product.id); + if (!existing || product.updated_at > existing.updated_at) { + dedupedById.set(product.id, product); + } + }); + + const dedupedByName = new Map(); + + dedupedById.forEach((product) => { + const normalizedName = normalizeName(product.name); + const existing = dedupedByName.get(normalizedName); + + if (!existing || product.updated_at > existing.updated_at) { + dedupedByName.set(normalizedName, product); + } + }); + + return Array.from(dedupedByName.values()).sort((a, b) => { + const normalizedNameA = normalizeName(a.name); + const normalizedNameB = normalizeName(b.name); + + const nameComparison = normalizedNameA.localeCompare(normalizedNameB, undefined, { + sensitivity: 'base', + }); + + if (nameComparison !== 0) { + return nameComparison; + } + + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); +}; diff --git a/src/utils/editableEntityUtils.ts b/src/utils/editableEntityUtils.ts new file mode 100644 index 0000000..3fe307d --- /dev/null +++ b/src/utils/editableEntityUtils.ts @@ -0,0 +1,25 @@ +import { AlertColor } from '@mui/material'; + +export interface ActionOutcome { + text?: string; + severity?: AlertColor; + success?: boolean; +} + +export interface FeedbackState { + text: string; + severity: AlertColor; +} + +export const applyOutcome = ( + setFeedback: (feedback: FeedbackState) => void, + outcome: ActionOutcome | void, + defaultText: string, + defaultSeverity: AlertColor = 'success', +) => { + const success = outcome?.success ?? outcome?.severity !== 'error'; + const text = outcome?.text ?? defaultText; + const severity = outcome?.severity ?? defaultSeverity; + setFeedback({ text, severity }); + return success; +}; diff --git a/src/utils/productRowUtils.ts b/src/utils/productRowUtils.ts new file mode 100644 index 0000000..0d5e9a3 --- /dev/null +++ b/src/utils/productRowUtils.ts @@ -0,0 +1,16 @@ +import { Product } from '../models/Product'; + +export interface ProductFormState { + name: string; + category: string; + barcode: string; +} + +export const getInitialFormState = ( + product: Product, + categoriesById: Map, +): ProductFormState => ({ + name: product.name, + category: categoriesById.get(product.category) ?? product.category ?? '', + barcode: product.barcode ?? '', +}); diff --git a/vite.config.ts b/vite.config.ts index d59db5a..7e45e50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -73,6 +73,14 @@ export default defineConfig({ include: ['src/**/*.{test,spec}.{ts,tsx}'], environment: 'jsdom', setupFiles: './src/test/setup.ts', - exclude: ['e2e/**/*'] + exclude: ['e2e/**/*'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + statements: 80, + branches: 80, + functions: 80, + lines: 80, + } } });