Add tests and utilities for coverage improvements

This commit is contained in:
beatz174-bit
2025-12-01 21:23:45 +10:00
parent 5d5edf7b47
commit 5bb16d155f
17 changed files with 840 additions and 76 deletions
+1 -1
View File
@@ -1 +1 @@
1.57.0
+11
View File
@@ -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.
+5 -22
View File
@@ -14,6 +14,8 @@ import {
TextField, TextField,
} from '@mui/material'; } from '@mui/material';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ActionOutcome, applyOutcome, FeedbackState } from '../utils/editableEntityUtils';
export type { ActionOutcome, FeedbackState } from '../utils/editableEntityUtils';
export interface EditableEntity { export interface EditableEntity {
id: string; id: string;
@@ -21,12 +23,6 @@ export interface EditableEntity {
secondaryText?: string; secondaryText?: string;
} }
export interface ActionOutcome {
text?: string;
severity?: AlertColor;
success?: boolean;
}
interface EditableEntityListProps { interface EditableEntityListProps {
nameLabel: string; nameLabel: string;
addButtonLabel?: string; addButtonLabel?: string;
@@ -39,11 +35,6 @@ interface EditableEntityListProps {
onDelete: (id: string, name: string) => Promise<ActionOutcome | void>; onDelete: (id: string, name: string) => Promise<ActionOutcome | void>;
} }
interface FeedbackState {
text: string;
severity: AlertColor;
}
export const EditableEntityList = ({ export const EditableEntityList = ({
nameLabel, nameLabel,
addButtonLabel = 'Add', addButtonLabel = 'Add',
@@ -69,19 +60,11 @@ export const EditableEntityList = ({
}; };
}, [validateName]); }, [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 handleAdd = async () => {
const trimmed = newName.trim(); const trimmed = newName.trim();
if (!isNameValid(newName, null)) return; if (!isNameValid(newName, null)) return;
try { try {
const success = applyOutcome(await onAdd(trimmed), `${entityLabel} added.`); const success = applyOutcome(setFeedback, await onAdd(trimmed), `${entityLabel} added.`);
if (success) { if (success) {
setNewName(''); setNewName('');
} }
@@ -107,7 +90,7 @@ export const EditableEntityList = ({
if (!isNameValid(editName, editingId)) return; if (!isNameValid(editName, editingId)) return;
try { try {
const success = applyOutcome(await onUpdate(editingId, trimmed), `${entityLabel} updated.`); const success = applyOutcome(setFeedback, await onUpdate(editingId, trimmed), `${entityLabel} updated.`);
if (success) { if (success) {
cancelEditing(); cancelEditing();
} }
@@ -118,7 +101,7 @@ export const EditableEntityList = ({
const handleDelete = async (id: string, name: string) => { const handleDelete = async (id: string, name: string) => {
try { try {
const success = applyOutcome(await onDelete(id, name), `${entityLabel} deleted.`); const success = applyOutcome(setFeedback, await onDelete(id, name), `${entityLabel} deleted.`);
if (success && editingId === id) { if (success && editingId === id) {
cancelEditing(); cancelEditing();
} }
+1 -13
View File
@@ -18,6 +18,7 @@ import CloseIcon from '@mui/icons-material/Close';
import { ChangeEvent, useEffect, useState } from 'react'; import { ChangeEvent, useEffect, useState } from 'react';
import { Product } from '../models/Product'; import { Product } from '../models/Product';
import { BarcodeScannerView } from './BarcodeScannerView'; import { BarcodeScannerView } from './BarcodeScannerView';
import { getInitialFormState, ProductFormState } from '../utils/productRowUtils';
interface ProductRowProps { interface ProductRowProps {
product: Product; product: Product;
@@ -36,19 +37,6 @@ interface ProductRowProps {
onDelete: (productId: string) => Promise<void> | void; onDelete: (productId: string) => Promise<void> | void;
} }
interface ProductFormState {
name: string;
category: string;
barcode: string;
}
const getInitialFormState = (product: Product, categoriesById: Map<string, string>): 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) => { export const ProductRow = ({ product, categories, categoriesById, onSave, onDelete }: ProductRowProps) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product, categoriesById)); const [formState, setFormState] = useState<ProductFormState>(() => getInitialFormState(product, categoriesById));
@@ -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 }) => (
<button type="button" onClick={() => onDetected?.('scanned-barcode')}>
Mock Barcode Scan
</button>
),
}));
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(
<AddProductDialog
open
onClose={vi.fn()}
categoryOptions={defaultCategories}
initialBarcode="12345"
/>,
);
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(
<AddProductDialog
open
onClose={vi.fn()}
categoryOptions={defaultCategories}
initialBarcode="999"
/>,
);
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(
<AddProductDialog open onClose={vi.fn()} categoryOptions={defaultCategories} />,
);
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(
<AddProductDialog open onClose={vi.fn()} categoryOptions={defaultCategories} />,
);
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);
});
});
});
@@ -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(
<EditableEntityList
nameLabel="Name"
entities={entities}
onAdd={onAdd}
onUpdate={vi.fn()}
onDelete={vi.fn()}
validateName={(value) => 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(
<EditableEntityList
nameLabel="Name"
entities={entities}
onAdd={onAdd}
onUpdate={onUpdate}
onDelete={vi.fn()}
/>,
);
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(
<EditableEntityList
nameLabel="Name"
entities={entities}
onAdd={vi.fn()}
onUpdate={onUpdate}
onDelete={onDelete}
/>,
);
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();
});
});
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; 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 { useState } from 'react';
import { NumericStepper } from '../NumericStepper'; import { NumericStepper } from '../NumericStepper';
@@ -54,4 +54,51 @@ describe('NumericStepper', () => {
expect(screen.getByRole('button', { name: 'decrease Items' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'decrease Items' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'increase 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(<NumericStepper label="Qty" value={1} min={1} onChange={handleChange} />);
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(<NumericStepper label="Qty" value={2} min={1} onChange={handleChange} />);
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 (
<NumericStepper
label="Qty"
value={value}
min={1}
onChange={(val) => {
handleChange(val);
setValue(val);
}}
/>
);
};
render(<Wrapper />);
const input = screen.getByRole('spinbutton', { name: /qty/i });
await user.clear(input);
await user.type(input, '7');
expect(handleChange).toHaveBeenLastCalledWith(7);
});
}); });
@@ -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(
<ProductAutocomplete
availableProducts={products}
onSelect={vi.fn()}
onQueryChange={onQueryChange}
/>,
);
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(
<ProductAutocomplete
availableProducts={products}
onSelect={vi.fn()}
onQueryChange={onQueryChange}
/>,
);
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(
<ProductAutocomplete availableProducts={products} onSelect={onSelect} onQueryChange={vi.fn()} />,
);
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(<ProductAutocomplete availableProducts={products} onSelect={vi.fn()} />);
const addButton = screen.getByLabelText(/add product/i);
expect(addButton).toBeDisabled();
render(
<ProductAutocomplete availableProducts={products} onSelect={vi.fn()} onAddProduct={vi.fn()} />, {
container: document.body.appendChild(document.createElement('div')),
},
);
expect(screen.getAllByLabelText(/add product/i).at(-1)).not.toBeDisabled();
});
});
@@ -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 }) => (
<button type="button" onClick={() => onDetected?.('scanned-code')}>
Mock Scan
</button>
),
}));
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<typeof vi.fn>;
let onDelete: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSave = vi.fn();
onDelete = vi.fn();
});
const renderRow = (productOverrides: Partial<typeof baseProduct> = {}) =>
render(
<ProductRow
product={{ ...baseProduct, ...productOverrides }}
categories={categories}
categoriesById={categoriesById}
onSave={onSave}
onDelete={onDelete}
/>,
);
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());
});
});
+2 -38
View File
@@ -33,8 +33,7 @@ import { PickItem } from '../models/PickItem';
import { Product } from '../models/Product'; import { Product } from '../models/Product';
import { ProductAutocomplete } from '../components/ProductAutocomplete'; import { ProductAutocomplete } from '../components/ProductAutocomplete';
import { AddProductDialog } from '../components/AddProductDialog'; import { AddProductDialog } from '../components/AddProductDialog';
import { dedupeByIdThenNameAndSort, normalizeName } from '../utils/activePickListUtils';
const normalizeName = (name: string) => name.trim().toLowerCase();
const ActivePickListScreen = () => { const ActivePickListScreen = () => {
const { id } = useParams(); const { id } = useParams();
@@ -117,42 +116,7 @@ const ActivePickListScreen = () => {
[areas, pickList?.area_id], [areas, pickList?.area_id],
); );
const sortedProducts = useMemo(() => { const sortedProducts = useMemo(() => dedupeByIdThenNameAndSort(products), [products]);
const dedupedById = new Map<string, Product>();
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<string, Product>();
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]);
// Build a category id -> name map for display and name -> id map for resolution // 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]); const categoriesById = useMemo(() => new Map(categoriesList.map((c) => [c.id, c.name])), [categoriesList]);
@@ -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<typeof userEvent.setup>) => {
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(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
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(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
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(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
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);
});
});
});
+5
View File
@@ -0,0 +1,5 @@
export function makeNamedError(name: string, message?: string) {
const error = new Error(message ?? name);
(error as any).name = name;
return error;
}
@@ -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([]);
});
});
+40
View File
@@ -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<string, Product>();
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<string, Product>();
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' });
});
};
+25
View File
@@ -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;
};
+16
View File
@@ -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<string, string>,
): ProductFormState => ({
name: product.name,
category: categoriesById.get(product.category) ?? product.category ?? '',
barcode: product.barcode ?? '',
});
+9 -1
View File
@@ -73,6 +73,14 @@ export default defineConfig({
include: ['src/**/*.{test,spec}.{ts,tsx}'], include: ['src/**/*.{test,spec}.{ts,tsx}'],
environment: 'jsdom', environment: 'jsdom',
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
exclude: ['e2e/**/*'] exclude: ['e2e/**/*'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
statements: 80,
branches: 80,
functions: 80,
lines: 80,
}
} }
}); });