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
+5 -22
View File
@@ -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<ActionOutcome | void>;
}
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();
}
+1 -13
View File
@@ -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> | 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) => {
const [isEditing, setIsEditing] = useState(false);
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 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(<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 { 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<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]);
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]);
@@ -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 ?? '',
});