modified: .codex_playwright_version

modified:   .vscode/launch.json
	modified:   package-lock.json
	modified:   package.json
	modified:   src/components/AddProductDialog.tsx
	new file:   src/screens/ActivePickListScreen.additional.test.tsx
	new file:   src/screens/ManageProductsScreen.additional.test.tsx
	modified:   src/screens/ManageProductsScreen.test.tsx
This commit is contained in:
2025-12-01 18:33:08 +10:00
parent 0f46e7b331
commit ca51bab86b
8 changed files with 454 additions and 21 deletions
+11 -2
View File
@@ -266,11 +266,14 @@ export const AddProductDialog = ({
open={open}
onClose={handleDialogClose}
aria-label="Add product dialog"
aria-labelledby="add-product-dialog-title"
data-testid="add-product-dialog"
fullWidth
maxWidth="sm"
PaperProps={{ role: 'form' }}
BackdropProps={{ 'data-testid': 'add-product-backdrop' }}
>
<DialogTitle sx={{ pr: 6 }}>
<DialogTitle id="add-product-dialog-title" sx={{ pr: 6 }}>
Add product
<IconButton
aria-label="Close add product"
@@ -332,7 +335,13 @@ export const AddProductDialog = ({
</Stack>
</DialogContent>
<Dialog open={scannerOpen} onClose={() => setScannerOpen(false)} aria-label="Scan barcode">
<Dialog
open={scannerOpen}
onClose={() => setScannerOpen(false)}
aria-label="Scan barcode"
data-testid="scan-barcode-dialog"
BackdropProps={{ 'data-testid': 'scan-barcode-backdrop' }}
>
<DialogTitle>Scan barcode</DialogTitle>
<DialogContent>
<BarcodeScannerView
@@ -0,0 +1,284 @@
// src/screens/ActivePickListScreen.additional.test.tsx
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen, within, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import ActivePickListScreen from './ActivePickListScreen';
import { PickItem } from '../models/PickItem';
import { Product } from '../models/Product';
const addMock = vi.fn();
const updateMock = vi.fn();
const deleteMock = vi.fn();
const pickItemsMock = vi.fn<() => PickItem[]>();
const productsMock = vi.fn<() => Product[]>();
const pickListMock = vi.fn();
// allow tests to mutate categories returned by the mocked hook
let categoriesVar: any[] = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }];
vi.mock('../hooks/dataHooks', () => ({
usePickItems: () => pickItemsMock(),
useProducts: () => productsMock(),
usePickList: () => pickListMock(),
useAreas: () => [{ id: 'area-1', name: 'Front Counter', created_at: 0, updated_at: 0 }],
useCategories: () => categoriesVar,
}));
vi.mock('../context/DBProvider', () => ({
useDatabase: () => ({
pickItems: {
add: addMock,
update: updateMock,
get: vi.fn(async (id: string) => {
const items: PickItem[] = pickItemsMock() ?? [];
return items.find((it) => it.id === id);
}),
delete: deleteMock,
where: (_f: string) => ({
equals: (val: any) => ({
toArray: async () => {
const items: PickItem[] = pickItemsMock() ?? [];
return items.filter((it) => it.pick_list_id === val);
},
count: async () => {
const items: PickItem[] = pickItemsMock() ?? [];
return items.filter((it) => it.pick_list_id === val).length;
},
}),
}),
},
}),
}));
const defaultProducts: Product[] = [
{
id: 'prod-1',
name: 'Cola',
category: 'cat-1',
unit_type: 'unit',
bulk_name: 'box',
barcode: '111',
archived: false,
created_at: 0,
updated_at: 0,
},
{
id: 'prod-2',
name: 'Chips',
category: 'cat-1',
unit_type: 'unit',
bulk_name: 'box',
barcode: '222',
archived: false,
created_at: 0,
updated_at: 0,
},
];
beforeEach(() => {
addMock.mockReset();
updateMock.mockReset();
deleteMock.mockReset();
pickItemsMock.mockReset();
productsMock.mockReset();
pickListMock.mockReset();
categoriesVar = [{ id: 'cat-1', name: 'Drinks', created_at: 0, updated_at: 0 }];
productsMock.mockReturnValue(defaultProducts);
pickListMock.mockReturnValue({
id: 'list-1',
area_id: 'area-1',
created_at: 0,
categories: ['cat-1'],
auto_add_new_products: false,
});
});
describe('ActivePickListScreen - additional branches', () => {
const getProductInput = () => {
const byTestId = screen.queryByTestId('product-search-input');
if (byTestId) return byTestId;
return screen.getByPlaceholderText('Search products');
};
it('increments / decrements / toggles carton / toggles status / deletes an item', async () => {
pickItemsMock.mockReturnValue([
{
id: 'item-1',
pick_list_id: 'list-1',
product_id: 'prod-1',
quantity: 1,
is_carton: false,
status: 'pending',
created_at: 0,
updated_at: 0,
},
]);
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/pick-lists/1']}>
<Routes>
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
</Routes>
</MemoryRouter>,
);
const inc = screen.getByLabelText('Increase quantity');
await user.click(inc);
expect(updateMock).toHaveBeenCalled();
const dec = screen.getByLabelText('Decrease quantity');
await user.click(dec);
expect(updateMock).toHaveBeenCalled();
const cartonButton = screen.getByLabelText(/Switch to carton packaging/i);
await user.click(cartonButton);
expect(updateMock).toHaveBeenCalled();
const checkbox = screen.getByLabelText('Toggle picked status') as HTMLInputElement;
await user.click(checkbox);
await waitFor(() => {
expect(updateMock).toHaveBeenCalled();
});
const deleteBtn = screen.getByLabelText('Delete item');
await user.click(deleteBtn);
const confirmDelete = await screen.findByRole('button', { name: /delete/i });
await user.click(confirmDelete);
expect(deleteMock).toHaveBeenCalledTimes(1);
});
it('handleMarkAllPicked updates items when items exist', async () => {
// normal case: itemState present -> mark all picked should update
pickItemsMock.mockReturnValue([
{
id: 'item-2',
pick_list_id: 'list-1',
product_id: 'prod-2',
quantity: 1,
is_carton: false,
status: 'pending',
created_at: 0,
updated_at: 0,
},
]);
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={['/pick-lists/1']}>
<Routes>
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
</Routes>
</MemoryRouter>,
);
const pickCompleteBtn = screen.getByRole('button', { name: /pick complete/i });
await user.click(pickCompleteBtn);
await waitFor(() => {
expect(updateMock).toHaveBeenCalled();
});
});
it('packaging radios are disabled when unique packaging count === 1 and enabled when both present', async () => {
// single packaging type (all units)
pickItemsMock.mockReturnValue([
{
id: 'item-3',
pick_list_id: 'list-1',
product_id: 'prod-1',
quantity: 1,
is_carton: false,
status: 'pending',
created_at: 0,
updated_at: 0,
},
]);
render(
<MemoryRouter initialEntries={['/pick-lists/1']}>
<Routes>
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
</Routes>
</MemoryRouter>,
);
const unitsWrapper = screen.getByTestId('packaging-filter-units');
const cartonsWrapper = screen.getByTestId('packaging-filter-cartons');
const unitsInput = (unitsWrapper as HTMLElement).querySelector('input') as HTMLInputElement;
const cartonsInput = (cartonsWrapper as HTMLElement).querySelector('input') as HTMLInputElement;
expect(unitsInput.disabled).toBe(true);
expect(cartonsInput.disabled).toBe(true);
// cleanup before re-rendering to avoid duplicate data-testid nodes
cleanup();
// both cartons and units present -> radios enabled
pickItemsMock.mockReturnValue([
{
id: 'item-4',
pick_list_id: 'list-1',
product_id: 'prod-1',
quantity: 1,
is_carton: false,
status: 'pending',
created_at: 0,
updated_at: 0,
},
{
id: 'item-5',
pick_list_id: 'list-1',
product_id: 'prod-2',
quantity: 1,
is_carton: true,
status: 'pending',
created_at: 0,
updated_at: 0,
},
]);
render(
<MemoryRouter initialEntries={['/pick-lists/1']}>
<Routes>
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
</Routes>
</MemoryRouter>,
);
const unitsWrapper2 = screen.getByTestId('packaging-filter-units');
const cartonsWrapper2 = screen.getByTestId('packaging-filter-cartons');
const unitsInput2 = (unitsWrapper2 as HTMLElement).querySelector('input') as HTMLInputElement;
const cartonsInput2 = (cartonsWrapper2 as HTMLElement).querySelector('input') as HTMLInputElement;
expect(unitsInput2.disabled).toBe(false);
expect(cartonsInput2.disabled).toBe(false);
});
it('categoryOptions resolves legacy names to ids when pickList.categories contains names', () => {
// Set categoriesVar so useCategories returns an id `c1` for 'Drinks'
categoriesVar = [{ id: 'c1', name: 'Drinks', created_at: 0, updated_at: 0 }];
// pickList with legacy name 'Drinks'
pickListMock.mockReturnValue({
id: 'list-1',
area_id: 'area-1',
created_at: 0,
categories: ['Drinks'],
auto_add_new_products: false,
});
render(
<MemoryRouter initialEntries={['/pick-lists/1']}>
<Routes>
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
</Routes>
</MemoryRouter>,
);
const select = screen.getByLabelText(/Filter by category/i) as HTMLSelectElement;
const option = Array.from(select.options).find((o) => o.value === 'c1');
expect(option).toBeDefined();
expect(option?.text).toMatch(/Drinks/i);
});
});
@@ -0,0 +1,130 @@
// src/screens/ManageProductsScreen.additional.test.tsx
import { MemoryRouter } from 'react-router-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ManageProductsScreen from './ManageProductsScreen';
const mockUseProducts = vi.fn();
const mockUseCategories = vi.fn();
vi.mock('../hooks/dataHooks', () => ({
useProducts: () => mockUseProducts(),
useCategories: () => mockUseCategories(),
}));
// DB mock
const productsDb = {
get: vi.fn(),
put: vi.fn(),
where: vi.fn(), // must return { equals: () => ({ first: async () => ... }) }
filter: vi.fn(),
};
const categoriesDb = {
where: vi.fn(),
get: vi.fn(),
add: vi.fn(),
};
const pickListsDb = {
toArray: vi.fn(),
};
const pickItemsDb = {
add: vi.fn(),
};
const mockDb: any = {
products: productsDb,
categories: categoriesDb,
pickLists: pickListsDb,
pickItems: pickItemsDb,
transaction: vi.fn(async (_mode: string, ...args: any[]) => {
const cb = args[args.length - 1];
if (typeof cb === 'function') return cb();
return undefined;
}),
};
vi.mock('../context/DBProvider', () => ({
useDatabase: () => mockDb,
}));
vi.mock('uuid', () => ({ v4: () => 'new-product-id' }));
beforeEach(() => {
mockUseProducts.mockReset();
mockUseCategories.mockReset();
Object.values(productsDb).forEach((f) => typeof f === 'function' && (f as any).mockReset && (f as any).mockReset());
Object.values(categoriesDb).forEach((f) => typeof f === 'function' && (f as any).mockReset && (f as any).mockReset());
pickListsDb.toArray.mockReset();
pickItemsDb.add.mockReset();
mockDb.transaction.mockReset();
// Default product DB stubs
productsDb.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
productsDb.filter.mockImplementation(() => ({ delete: vi.fn() }));
});
describe('ManageProductsScreen update product category creation branch', () => {
it('creates a new category when updates.category is an unknown name and saves product', async () => {
const existingProduct = {
id: 'prod-2',
name: 'Another Product',
category: 'cat-old',
barcode: '654321',
unit_type: 'unit',
bulk_name: 'pack',
archived: false,
created_at: 0,
updated_at: 0,
};
mockUseProducts.mockReturnValue([existingProduct]);
mockUseCategories.mockReturnValue([]); // no categories available
productsDb.get.mockResolvedValue(existingProduct);
// products.where('barcode').equals(value).first() must exist for uniqueness check
productsDb.where.mockImplementation((field?: string) => ({
equals: (value?: string) => ({
first: async () => undefined,
}),
}));
// categories.where('name').equals(...).first => undefined (no matching category name)
categoriesDb.where.mockImplementation(() => ({ equals: () => ({ first: async () => undefined }) }));
categoriesDb.get.mockResolvedValue(undefined); // isExistingId check -> undefined
categoriesDb.add.mockResolvedValue('new-cat-id');
productsDb.put.mockResolvedValue('prod-2');
productsDb.filter.mockImplementation(() => ({ delete: vi.fn() }));
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
// Open edit for the product
await user.click(screen.getByLabelText(/edit another product/i));
// Find the category field corresponding to that row (value should be 'cat-old')
const categoryInputs = screen.getAllByLabelText(/category/i);
const categoryField = categoryInputs.find((i) => (i as HTMLInputElement).value === 'cat-old') as HTMLInputElement;
expect(categoryField).toBeDefined();
// Change category to a new name that doesn't exist
fireEvent.change(categoryField, { target: { value: 'New Category Name' } });
// Click product-row Save button
const saveButtons = screen.getAllByRole('button', { name: /save product/i });
const productSaveButton = saveButtons.find((b) => b.getAttribute('aria-label') === 'Save product' || b.querySelector('svg') !== null) ?? saveButtons[0];
expect(productSaveButton).toBeDefined();
await user.click(productSaveButton as HTMLElement);
// Wait for categories.add to be called (new category created) and product saved
await waitFor(() => {
expect(categoriesDb.add).toHaveBeenCalled();
expect(productsDb.put).toHaveBeenCalled();
});
});
});
+21 -6
View File
@@ -251,16 +251,31 @@ describe('ManageProductsScreen barcode lookup', () => {
</MemoryRouter>,
);
// open dialog and wait for it to be present
await openAddProductDialog(user);
await screen.findByTestId('add-product-dialog');
await user.type(screen.getByLabelText(/name/i), 'existing product');
// click the close icon and wait for dialog to be removed
await user.click(screen.getByRole('button', { name: /close add product/i }));
await waitFor(() => {
expect(screen.queryByTestId('add-product-dialog')).not.toBeInTheDocument();
}, { timeout: 2000 });
const saveBtn = findSaveButton();
expect(saveBtn).toBeTruthy();
await user.click(saveBtn as HTMLElement);
// reopen the dialog and wait for it to appear
await openAddProductDialog(user);
const dialog = await screen.findByTestId('add-product-dialog', {}, { timeout: 2000 });
expect(dialog).toBeInTheDocument();
// find the MUI backdrop by test-id and click it
const backdrop = await screen.findByTestId('add-product-backdrop', {}, { timeout: 2000 });
expect(backdrop).toBeTruthy();
await user.click(backdrop);
// finally wait for dialog to be removed
await waitFor(() => {
expect(screen.queryByTestId('add-product-dialog')).not.toBeInTheDocument();
}, { timeout: 2000 });
expect(await screen.findByText(/product with this name already exists/i)).toBeVisible();
expect(mockDb.products.add).not.toHaveBeenCalled();
});
it('informs the user when barcode lookup happens offline', async () => {