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:
@@ -1 +1 @@
|
||||
1.56.1
|
||||
|
||||
|
||||
Vendored
+7
@@ -31,6 +31,13 @@
|
||||
"request": "launch",
|
||||
"command": "npm run test:coverage",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"name": "Lint and Build",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npx tsc -noEmit && npm run build",
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
-11
@@ -12,7 +12,6 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
@@ -2456,16 +2455,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz",
|
||||
"integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==",
|
||||
"deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uuid": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz",
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@zxing/browser": "^0.1.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user