Expand unit test coverage
modified: maintenance.sh modified: package-lock.json modified: package.json new file: src/screens/ActivePickListScreen.more.test.tsx new file: src/screens/ManageCategoriesScreen.additional.test.tsx new file: src/testUtils/mockDb.test.ts
This commit is contained in:
+5
-2
@@ -23,8 +23,9 @@ if [ -f package.json ]; then
|
|||||||
# npm ci will reconcile. We do a cheap check by comparing mtime.
|
# npm ci will reconcile. We do a cheap check by comparing mtime.
|
||||||
if [ -f package-lock.json ]; then
|
if [ -f package-lock.json ]; then
|
||||||
if [ package-lock.json -nt node_modules ]; then
|
if [ package-lock.json -nt node_modules ]; then
|
||||||
echo "package-lock.json newer than node_modules; running npm ci..."
|
echo "package-lock.json newer than node_modules; running npm install & npm prune..."
|
||||||
npm ci
|
npm install
|
||||||
|
npm prune
|
||||||
else
|
else
|
||||||
echo "npm deps look current; skipping install."
|
echo "npm deps look current; skipping install."
|
||||||
fi
|
fi
|
||||||
@@ -36,6 +37,8 @@ else
|
|||||||
echo "-> No package.json; skipping Node maintenance."
|
echo "-> No package.json; skipping Node maintenance."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
npx npm-check-updates -u
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# 2) Refresh Playwright Chromium if Playwright version changed
|
# 2) Refresh Playwright Chromium if Playwright version changed
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|||||||
Generated
+243
-492
File diff suppressed because it is too large
Load Diff
+9
-8
@@ -21,6 +21,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
"npm-check-updates": "^19.1.2",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.57.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
@@ -37,11 +38,11 @@
|
|||||||
"@types/papaparse": "^5.5.0",
|
"@types/papaparse": "^5.5.0",
|
||||||
"@types/react": "^19.2.7",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.47.0",
|
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||||
"@typescript-eslint/parser": "^8.47.0",
|
"@typescript-eslint/parser": "^8.48.0",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"@vitest/coverage-v8": "^4.0.13",
|
"@vitest/coverage-v8": "^4.0.14",
|
||||||
"@vitest/ui": "^4.0.13",
|
"@vitest/ui": "^4.0.14",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
@@ -49,8 +50,8 @@
|
|||||||
"msw": "^2.12.3",
|
"msw": "^2.12.3",
|
||||||
"rollup-plugin-visualizer": "^6.0.5",
|
"rollup-plugin-visualizer": "^6.0.5",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.47.0",
|
"typescript-eslint": "^8.48.0",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.6",
|
||||||
"vitest": "^4.0.13"
|
"vitest": "^4.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// src/screens/ActivePickListScreen.more.test.tsx
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { render, screen, within, waitFor } 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();
|
||||||
|
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
usePickItems: () => pickItemsMock(),
|
||||||
|
useProducts: () => productsMock(),
|
||||||
|
usePickList: () => pickListMock(),
|
||||||
|
useAreas: () => [{ id: 'a1', name: 'Front', created_at: 0, updated_at: 0 }],
|
||||||
|
useCategories: () => [{ id: 'c1', name: 'Drinks', created_at: 0, updated_at: 0 }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
pickItems: {
|
||||||
|
add: addMock,
|
||||||
|
update: updateMock,
|
||||||
|
get: vi.fn(async (id: string) => (pickItemsMock() || []).find((it) => it.id === id)),
|
||||||
|
delete: deleteMock,
|
||||||
|
where: (_f: string) => ({
|
||||||
|
equals: (val: any) => ({
|
||||||
|
toArray: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val),
|
||||||
|
count: async () => (pickItemsMock() || []).filter((it) => it.pick_list_id === val).length,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{ id: 'prod-1', name: 'Cola', category: 'c1', unit_type: 'unit', bulk_name: 'box', barcode: '111', archived: false, created_at: 0, updated_at: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
addMock.mockReset();
|
||||||
|
updateMock.mockReset();
|
||||||
|
deleteMock.mockReset();
|
||||||
|
pickItemsMock.mockReset();
|
||||||
|
productsMock.mockReset();
|
||||||
|
pickListMock.mockReset();
|
||||||
|
|
||||||
|
productsMock.mockReturnValue(products);
|
||||||
|
pickListMock.mockReturnValue({ id: 'list-1', area_id: 'a1', created_at: 0, categories: ['c1'], auto_add_new_products: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ActivePickListScreen extra branches', () => {
|
||||||
|
it('adds a pick item when a product is selected (listbox opens)', async () => {
|
||||||
|
// Ensure the product is available (not already on the pick list)
|
||||||
|
pickItemsMock.mockReturnValue([]);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={['/pick-lists/1']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/pick-lists/:id" element={<ActivePickListScreen />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the combobox (product input), open it and select the option
|
||||||
|
const combobox = screen.getByRole('combobox', { name: /search products/i }) || screen.getByTestId('product-search-input');
|
||||||
|
await user.click(combobox);
|
||||||
|
|
||||||
|
// Wait for the listbox to appear and click the option
|
||||||
|
const listbox = await screen.findByRole('listbox');
|
||||||
|
await user.click(within(listbox).getByRole('option', { name: /cola/i }));
|
||||||
|
|
||||||
|
// Assert add was called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addMock).toHaveBeenCalled();
|
||||||
|
const added = addMock.mock.calls[0][0];
|
||||||
|
expect(added).toEqual(expect.objectContaining({ product_id: 'prod-1', quantity: 1, is_carton: false }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No items match the filter" when visible items exist but none match search', 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 listSearch = screen.getByPlaceholderText(/search list/i) || screen.getByRole('textbox', { name: /search list/i });
|
||||||
|
await user.type(listSearch, 'nomatchtext');
|
||||||
|
|
||||||
|
expect(await screen.findByText(/no items match the filter/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categoryFilter restricts visible items to selected category', async () => {
|
||||||
|
productsMock.mockReturnValue([
|
||||||
|
...products,
|
||||||
|
{ id: 'prod-2', name: 'Chips', category: 'other', unit_type: 'unit', bulk_name: 'box', created_at: 0, updated_at: 0, archived: false },
|
||||||
|
]);
|
||||||
|
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 },
|
||||||
|
{ 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 select = screen.getByLabelText(/filter by category/i) as HTMLSelectElement;
|
||||||
|
await user.selectOptions(select, ['c1']);
|
||||||
|
|
||||||
|
expect(screen.getByText(/cola/i)).toBeVisible();
|
||||||
|
expect(screen.queryByText(/chips/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// src/screens/ManageCategoriesScreen.additional.test.tsx
|
||||||
|
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 ManageCategoriesScreen from './ManageCategoriesScreen';
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 },
|
||||||
|
{ id: 'cat-2', name: 'Drinks', created_at: 0, updated_at: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
{ id: 'prod-1', name: 'Chips', category: 'Snacks', archived: false, created_at: 0, updated_at: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const categoryAddMock = vi.fn();
|
||||||
|
const categoryUpdateMock = vi.fn();
|
||||||
|
const categoryDeleteMock = vi.fn();
|
||||||
|
const productModifyMock = vi.fn();
|
||||||
|
const pickListsUpdateMock = vi.fn();
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
vi.mock('../hooks/dataHooks', () => ({
|
||||||
|
useCategories: () => categories,
|
||||||
|
useProducts: () => products,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DBProvider to catch pickLists.update and categories.update/delete
|
||||||
|
vi.mock('../context/DBProvider', () => ({
|
||||||
|
useDatabase: () => ({
|
||||||
|
transaction: async (...args: unknown[]) => {
|
||||||
|
const cb = args[args.length - 1];
|
||||||
|
if (typeof cb === 'function') await (cb as () => Promise<void>)();
|
||||||
|
},
|
||||||
|
categories: { add: categoryAddMock, update: categoryUpdateMock, delete: categoryDeleteMock },
|
||||||
|
products: {
|
||||||
|
where: () => ({
|
||||||
|
equals: (_: string) => ({
|
||||||
|
count: async () => 0,
|
||||||
|
modify: async (changes: any) => productModifyMock(changes),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
pickLists: {
|
||||||
|
toArray: async () => [{ id: 'pl-1', categories: ['Snacks'], created_at: 0, updated_at: 0 }],
|
||||||
|
update: pickListsUpdateMock,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ManageCategoriesScreen additional', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
categoryAddMock.mockReset();
|
||||||
|
categoryUpdateMock.mockReset();
|
||||||
|
categoryDeleteMock.mockReset();
|
||||||
|
productModifyMock.mockReset();
|
||||||
|
pickListsUpdateMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates pickLists when category name is changed (legacy name in pickLists.categories)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageCategoriesScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /edit snacks/i }));
|
||||||
|
const editField = screen.getByDisplayValue(/snacks/i);
|
||||||
|
await user.clear(editField);
|
||||||
|
await user.type(editField, 'Treats');
|
||||||
|
await user.click(screen.getByRole('button', { name: /save category/i }));
|
||||||
|
|
||||||
|
// categories.update should have been called
|
||||||
|
expect(categoryUpdateMock).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// and pickLists.update should have been called to update the categories array
|
||||||
|
expect(pickListsUpdateMock).toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText(/category updated/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes category when no products reference it', async () => {
|
||||||
|
// products.where().equals().count() is 0 in our mock, so delete should proceed
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageCategoriesScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click the "Delete Snacks" icon/button (specific aria-label)
|
||||||
|
await user.click(screen.getByRole('button', { name: /delete snacks/i }));
|
||||||
|
|
||||||
|
// Wait for the delete mock to be called (component calls the onDelete handler)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(categoryDeleteMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// And assert the success alert is shown
|
||||||
|
expect(await screen.findByText(/category deleted/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents renaming to an existing name', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ManageCategoriesScreen />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /edit snacks/i }));
|
||||||
|
const editField = screen.getByDisplayValue(/snacks/i);
|
||||||
|
await user.clear(editField);
|
||||||
|
await user.type(editField, 'Drinks'); // name that already exists
|
||||||
|
await user.click(screen.getByRole('button', { name: /save category/i }));
|
||||||
|
|
||||||
|
// Should show "already exists" and NOT call update
|
||||||
|
expect(categoryUpdateMock).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText(/already exists/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// src/testUtils/mockDb.test.ts
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MockTable, createMockDb } from './mockDb';
|
||||||
|
|
||||||
|
type Item = { id: string; category?: string; name?: string };
|
||||||
|
|
||||||
|
describe('MockTable basic operations', () => {
|
||||||
|
it('supports toArray, add, get, put (update + insert) and delete', async () => {
|
||||||
|
const t = new MockTable<Item>([{ id: 'a', name: 'alpha' }]);
|
||||||
|
|
||||||
|
// toArray
|
||||||
|
expect(await t.toArray()).toHaveLength(1);
|
||||||
|
|
||||||
|
// add
|
||||||
|
await t.add({ id: 'b', name: 'beta' });
|
||||||
|
expect((await t.toArray()).map((i) => i.id)).toContain('b');
|
||||||
|
|
||||||
|
// get
|
||||||
|
expect((await t.get('b'))?.name).toBe('beta');
|
||||||
|
|
||||||
|
// put (update)
|
||||||
|
await t.put({ id: 'a', name: 'alpha-updated' });
|
||||||
|
expect((await t.get('a'))?.name).toBe('alpha-updated');
|
||||||
|
|
||||||
|
// put (insert)
|
||||||
|
await t.put({ id: 'c', name: 'gamma' });
|
||||||
|
expect((await t.toArray()).map((i) => i.id)).toContain('c');
|
||||||
|
|
||||||
|
// delete
|
||||||
|
await t.delete('b');
|
||||||
|
expect(await t.get('b')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('where().equals().first / count / filter works and filter.delete removes items', async () => {
|
||||||
|
const t = new MockTable<Item>([
|
||||||
|
{ id: '1', category: 'x' },
|
||||||
|
{ id: '2', category: 'x' },
|
||||||
|
{ id: '3', category: 'y' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// first
|
||||||
|
const firstX = await t.where('category').equals('x').first();
|
||||||
|
expect(firstX?.id).toBe('1');
|
||||||
|
|
||||||
|
// count
|
||||||
|
const countX = await t.where('category').equals('x').count();
|
||||||
|
expect(countX).toBe(2);
|
||||||
|
|
||||||
|
// equals(...).filter(...).first()
|
||||||
|
const filteredFirst = await t.where('category').equals('x').filter((it: any) => it.id === '2').first();
|
||||||
|
expect(filteredFirst?.id).toBe('2');
|
||||||
|
|
||||||
|
// filter(pred).delete() and filter(...).first()
|
||||||
|
const before = await t.toArray();
|
||||||
|
expect(before.some((i) => i.category === 'y')).toBeTruthy();
|
||||||
|
|
||||||
|
const f = t.filter((it: any) => it.category === 'y');
|
||||||
|
await f.delete();
|
||||||
|
const after = await t.toArray();
|
||||||
|
expect(after.some((i) => i.category === 'y')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transaction executes callback when provided and returns undefined otherwise', async () => {
|
||||||
|
const db = createMockDb({});
|
||||||
|
let called = false;
|
||||||
|
await db.transaction('rw', async () => {
|
||||||
|
called = true;
|
||||||
|
});
|
||||||
|
expect(called).toBe(true);
|
||||||
|
|
||||||
|
// when last arg isn't a function, it should resolve and return undefined
|
||||||
|
const res = await db.transaction('rw');
|
||||||
|
expect(res).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user