Add tests and utilities for coverage improvements
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 ?? '',
|
||||
});
|
||||
Reference in New Issue
Block a user