Merge pull request #148 from beatz174-bit/codex/increase-unit-test-coverage-and-refactor-utilities

Add utilities and test helpers
This commit is contained in:
beatz174-bit
2025-12-01 16:08:56 +10:00
committed by GitHub
14 changed files with 460 additions and 159 deletions
+18
View File
@@ -0,0 +1,18 @@
# Testing
Run the unit and component test suite with:
```
npm test
```
For coverage reports:
```
npm run test:coverage
```
## Test utilities
- `src/testUtils/mockDb.ts` provides a lightweight `createMockDb` factory with `MockTable` helpers that mimic Dexie tables used in the app.
- `src/testUtils/stubDownloads.ts` stubs `document.createElement` and `URL.createObjectURL`/`revokeObjectURL` so download paths can be exercised in tests.
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest", "test": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
+15
View File
@@ -0,0 +1,15 @@
export const isOnline = () => {
if (typeof navigator !== 'undefined' && 'onLine' in navigator) {
return navigator.onLine;
}
return true;
};
export const triggerDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
+1 -2
View File
@@ -31,8 +31,7 @@ import { PickItemRow } from '../components/PickItemRow';
import { PickItem } from '../models/PickItem'; import { PickItem } from '../models/PickItem';
import { Product } from '../models/Product'; import { Product } from '../models/Product';
import { ProductAutocomplete } from '../components/ProductAutocomplete'; import { ProductAutocomplete } from '../components/ProductAutocomplete';
import { normalizeName } from '../utils/stringUtils';
const normalizeName = (name: string) => name.trim().toLowerCase();
const ActivePickListScreen = () => { const ActivePickListScreen = () => {
const { id } = useParams(); const { id } = useParams();
@@ -0,0 +1,151 @@
import { MemoryRouter } from 'react-router-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import ManageProductsScreen from '../ManageProductsScreen';
import { createMockDb } from '../../testUtils/mockDb';
const mockUseProducts = vi.fn();
const mockUseCategories = vi.fn();
let mockDb = createMockDb();
vi.mock('../../hooks/dataHooks', () => ({
useProducts: () => mockUseProducts(),
useCategories: () => mockUseCategories(),
}));
vi.mock('../../context/DBProvider', () => ({
useDatabase: () => mockDb,
}));
vi.mock('../../components/BarcodeScannerView', () => ({
BarcodeScannerView: ({ onDetected }: { onDetected?: (code: string) => void }) => (
<button type="button" onClick={() => onDetected?.('dup-barcode')}>
Mock Scan
</button>
),
}));
const clickSaveButton = async (user: ReturnType<typeof userEvent.setup>) => {
const saveButton = screen
.getAllByRole('button')
.find((btn) => /save product/i.test(btn.textContent || btn.getAttribute('aria-label') || ''));
if (!saveButton) throw new Error('Save button not found');
await user.click(saveButton);
};
const selectCategory = async (user: ReturnType<typeof userEvent.setup>, name: string) => {
const categorySelect = screen.getByLabelText(/^category/i);
await user.click(categorySelect);
const option = await screen.findByRole('option', { name });
await user.click(option);
};
beforeEach(() => {
mockDb = createMockDb();
mockUseProducts.mockReturnValue([]);
mockUseCategories.mockReturnValue([
{ id: 'cat-1', name: 'Snacks', created_at: 0, updated_at: 0 },
]);
});
describe('ManageProductsScreen add and update flows', () => {
it('creates category when missing and adds product', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
await user.type(screen.getByLabelText(/name/i), 'New Product');
await selectCategory(user, 'Snacks');
await user.type(screen.getByLabelText(/barcode/i), '111');
await clickSaveButton(user);
await waitFor(() => {
expect(mockDb.categories.items.find((c) => c.name === 'Snacks')).toBeTruthy();
expect(mockDb.products.items.find((p) => p.name === 'New Product')).toBeTruthy();
});
});
it('prevents duplicate barcode and shows error', async () => {
mockUseProducts.mockReturnValue([
{
id: 'existing',
name: 'Existing',
category: 'c1',
barcode: 'dup-barcode',
unit_type: 'unit',
bulk_name: 'carton',
archived: false,
created_at: 0,
updated_at: 0,
},
]);
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
await user.type(screen.getByLabelText(/name/i), 'Another');
await selectCategory(user, 'Snacks');
await user.click(screen.getByText(/scan barcode/i));
await user.click(screen.getByText(/mock scan/i));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await clickSaveButton(user);
await waitFor(() => {
expect(screen.getByTestId('barcode-error')).toBeInTheDocument();
expect(mockDb.products.items.find((p) => p.name === 'Another')).toBeUndefined();
});
});
it('auto-adds new 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: 'List One',
categories: ['Fresh'],
auto_add_new_products: true,
},
{
id: 'list-2',
area_id: 'b',
created_at: timestamp,
completed_at: undefined,
notes: 'List Two',
categories: ['different'],
auto_add_new_products: true,
},
],
categories: [{ id: 'fresh-id', name: 'Fresh', created_at: timestamp, updated_at: timestamp }],
});
mockUseProducts.mockReturnValue([]);
mockUseCategories.mockReturnValue([{ id: 'fresh-id', name: 'Fresh', created_at: timestamp, updated_at: timestamp }]);
const user = userEvent.setup();
render(
<MemoryRouter>
<ManageProductsScreen />
</MemoryRouter>,
);
await user.type(screen.getByLabelText(/name/i), 'Lettuce');
await selectCategory(user, 'Fresh');
await clickSaveButton(user);
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,33 @@
import { describe, expect, it, vi } from 'vitest';
import { exportData } from '../importExportService';
import { createMockDb } from '../../testUtils/mockDb';
import { stubDownloads } from '../../testUtils/stubDownloads';
describe('exportData single file', () => {
it('exports a single csv and logs download', async () => {
const db = createMockDb({
products: [
{
id: 'p1',
name: 'Test Product',
category: 'c1',
unit_type: 'unit',
bulk_name: 'carton',
barcode: '123',
archived: false,
created_at: 1,
updated_at: 1,
},
],
categories: [{ id: 'c1', name: 'Snacks', created_at: 1, updated_at: 1 }],
});
const download = stubDownloads(vi);
const result = await exportData(db as any, ['products']);
expect(result.fileName).toBe('products.csv');
expect(download.createObjectURL).toHaveBeenCalled();
expect((db.importExportLogs as any).items).toHaveLength(1);
});
});
@@ -0,0 +1,34 @@
import Papa from 'papaparse';
import { describe, expect, it, vi, afterEach } from 'vitest';
import { importFiles } from '../importExportService';
import { createMockDb } from '../../testUtils/mockDb';
const emptyNameCsv = ['name,category,barcode', ',,'].join('\n');
const createFile = (name: string, content: string) => ({
name,
text: async () => content,
}) as unknown as File;
describe('importFiles error handling', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('throws when CSV parsing reports errors', async () => {
vi.spyOn(Papa, 'parse').mockReturnValue({ data: [], errors: [{ message: 'parse error' }] } as any);
const db = createMockDb();
await expect(importFiles(db as any, [createFile('products.csv', '')], { allowAutoCreateMissing: true })).rejects.toThrow(
/parse error/,
);
});
it('logs skipped product rows with empty names', async () => {
const db = createMockDb();
const result = await importFiles(db as any, [createFile('products.csv', emptyNameCsv)], {
allowAutoCreateMissing: true,
});
expect(result.log.details.some((line) => line.includes('Skipped product with empty name'))).toBe(true);
expect(result.log.summary.skipped).toBeGreaterThanOrEqual(1);
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { normalizeName, inferTypeFromName } from '../../utils/stringUtils';
import { coerceBoolean, coerceNumber } from '../../utils/convUtils';
describe('import/export utils', () => {
it('normalizes names and preserves empty as empty string', () => {
expect(normalizeName(' Hello WORLD ')).toBe('hello world');
expect(normalizeName(undefined)).toBe('');
});
it('infers data type from filenames', () => {
expect(inferTypeFromName('products.csv')).toBe('products');
expect(inferTypeFromName('pickitems.csv')).toBe('pick-items');
expect(inferTypeFromName('unknown.txt')).toBeUndefined();
});
it('coerces booleans correctly', () => {
expect(coerceBoolean('true')).toBe(true);
expect(coerceBoolean('false')).toBe(false);
expect(coerceBoolean(true)).toBe(true);
expect(coerceBoolean(undefined)).toBe(false);
});
it('coerces numbers with fallback to zero', () => {
expect(coerceNumber('123')).toBe(123);
expect(coerceNumber(42)).toBe(42);
expect(coerceNumber('not-a-number')).toBe(0);
expect(coerceNumber(undefined)).toBe(0);
});
});
+30 -86
View File
@@ -1,71 +1,18 @@
import JSZip from 'jszip'; import JSZip from 'jszip';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { describe, expect, it, vi, afterEach } from 'vitest';
import { exportData, importFiles } from './importExportService'; import { exportData, importFiles } from './importExportService';
import { Product } from '../models/Product'; import { createMockDb } from '../testUtils/mockDb';
import { Category } from '../models/Category'; import { stubDownloads } from '../testUtils/stubDownloads';
import { Area } from '../models/Area';
import { PickList } from '../models/PickList';
import { PickItem } from '../models/PickItem';
import { StockFillDB } from '../db';
class MockTable<T extends { id: string }> { const csvContent = [
constructor(public items: T[] = []) {} 'id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at',
'p2,Existing,Clothing,unit,carton,123,false,,',
'p3,New Shirt,New Category,unit,carton,124,false,,',
].join('\n');
async toArray() { const csv = { name: 'products.csv', text: async () => csvContent } as unknown as File;
return [...this.items];
}
async add(item: T) { const baseDb = createMockDb({
this.items.push(item);
return item.id;
}
async get(id: string) {
return this.items.find((item) => item.id === id);
}
}
const createMockDb = (data?: {
products?: Product[];
categories?: Category[];
areas?: Area[];
pickLists?: PickList[];
pickItems?: PickItem[];
}) => {
const db = {
products: new MockTable<Product>(data?.products ?? []),
categories: new MockTable<Category>(data?.categories ?? []),
areas: new MockTable<Area>(data?.areas ?? []),
pickLists: new MockTable<PickList>(data?.pickLists ?? []),
pickItems: new MockTable<PickItem>(data?.pickItems ?? []),
importExportLogs: new MockTable<any>([]),
transaction: async (_mode: string, ...args: any[]) => {
const callback = args[args.length - 1];
return callback();
},
} as unknown as any;
return db;
};
const stubDownloads = () => {
const anchor = { href: '', download: '', click: vi.fn() } as unknown as HTMLAnchorElement;
const createObjectURL = vi.fn(() => 'blob:url');
const revokeObjectURL = vi.fn();
// @ts-ignore jsdom stub
vi.stubGlobal('document', { createElement: () => anchor });
// @ts-ignore jsdom stub
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
return { anchor, createObjectURL, revokeObjectURL };
};
describe('import/export service', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('exports multiple types into a zip with friendly values', async () => {
const db = createMockDb({
products: [ products: [
{ {
id: 'p1', id: 'p1',
@@ -79,12 +26,8 @@ describe('import/export service', () => {
updated_at: 2, updated_at: 2,
}, },
], ],
categories: [ categories: [{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 2 }],
{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 2 }, areas: [{ id: 'a1', name: 'Area A', created_at: 1, updated_at: 2 }],
],
areas: [
{ id: 'a1', name: 'Area A', created_at: 1, updated_at: 2 },
],
pickLists: [ pickLists: [
{ {
id: 'l1', id: 'l1',
@@ -109,9 +52,23 @@ describe('import/export service', () => {
}, },
], ],
}); });
stubDownloads();
const result = await exportData(db, ['products', 'categories', 'pick-lists', 'pick-items']); describe('import/export service', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('exports multiple types into a zip with friendly values', async () => {
const db = createMockDb({
products: baseDb.products.items,
categories: baseDb.categories.items,
areas: baseDb.areas.items,
pickLists: baseDb.pickLists.items,
pickItems: baseDb.pickItems.items,
});
stubDownloads(vi);
const result = await exportData(db as any, ['products', 'categories', 'pick-lists', 'pick-items']);
const zip = await JSZip.loadAsync(result.blob as any); const zip = await JSZip.loadAsync(result.blob as any);
const productCsv = await zip.file('products.csv')!.async('string'); const productCsv = await zip.file('products.csv')!.async('string');
const pickItemsCsv = await zip.file('pickitems.csv')!.async('string'); const pickItemsCsv = await zip.file('pickitems.csv')!.async('string');
@@ -138,26 +95,13 @@ describe('import/export service', () => {
], ],
categories: [{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 1 }], categories: [{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 1 }],
}); });
stubDownloads(); stubDownloads(vi);
const csvContent = [ const result = await importFiles(db as any, [csv], { allowAutoCreateMissing: true }, () => undefined);
'id,name,category,unit_type,bulk_name,barcode,archived,created_at,updated_at',
'p2,Existing,Clothing,unit,carton,123,false,,',
'p3,New Shirt,New Category,unit,carton,124,false,,',
].join('\n');
const csv = { name: 'products.csv', text: async () => csvContent } as unknown as File;
const result = await importFiles(
db,
[csv],
{ allowAutoCreateMissing: true },
() => undefined,
);
expect(result.log.summary.inserted).toBeGreaterThanOrEqual(1); expect(result.log.summary.inserted).toBeGreaterThanOrEqual(1);
expect((db.products as any).items.find((p: any) => p.name === 'Existing')).toBeTruthy(); expect((db.products as any).items.find((p: any) => p.name === 'Existing')).toBeTruthy();
expect((db.products as any).items.find((p: any) => p.name === 'New Shirt')).toBeTruthy(); expect((db.products as any).items.find((p: any) => p.name === 'New Shirt')).toBeTruthy();
expect((db.categories as any).items.find((c: any) => c.name === 'New Category')).toBeTruthy(); expect((db.categories as any).items.find((c: any) => c.name === 'New Category')).toBeTruthy();
}); });
}); });
+6 -40
View File
@@ -8,6 +8,11 @@ import { Category } from '../models/Category';
import { PickItem, PickItemStatus } from '../models/PickItem'; import { PickItem, PickItemStatus } from '../models/PickItem';
import { PickList } from '../models/PickList'; import { PickList } from '../models/PickList';
import { Product, DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product'; import { Product, DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE } from '../models/Product';
import { normalizeName, inferTypeFromName } from '../utils/stringUtils';
import { coerceBoolean, coerceNumber } from '../utils/convUtils';
import { triggerDownload } from '../platform/web';
export { normalizeName, inferTypeFromName } from '../utils/stringUtils';
export { coerceBoolean, coerceNumber } from '../utils/convUtils';
export type DataType = 'areas' | 'categories' | 'products' | 'pick-lists' | 'pick-items'; export type DataType = 'areas' | 'categories' | 'products' | 'pick-lists' | 'pick-items';
@@ -30,24 +35,6 @@ interface ParsedFile {
content: string; content: string;
} }
const typeHints: Record<DataType, string[]> = {
areas: ['area'],
categories: ['category'],
products: ['product'],
'pick-lists': ['picklist', 'pick-list'],
'pick-items': ['pickitem', 'pick-item'],
};
export const normalizeName = (value?: string) =>
(value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
const inferTypeFromName = (name: string): DataType | undefined => {
const lowercase = name.toLowerCase();
return (Object.keys(typeHints) as DataType[]).find((key) =>
typeHints[key].some((hint) => lowercase.includes(hint)),
);
};
const parseCsv = async (content: string) => { const parseCsv = async (content: string) => {
const result = Papa.parse<Record<string, string>>(content, { const result = Papa.parse<Record<string, string>>(content, {
header: true, header: true,
@@ -101,15 +88,6 @@ const createLog = (
const serializeCsv = (rows: object[]) => Papa.unparse(rows, { quotes: true }); const serializeCsv = (rows: object[]) => Papa.unparse(rows, { quotes: true });
const triggerDownload = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
};
export const exportData = async ( export const exportData = async (
db: StockFillDB, db: StockFillDB,
selectedTypes: DataType[], selectedTypes: DataType[],
@@ -250,18 +228,6 @@ const addOrUpdateMap = (map: Map<string, string>, key: string, value: string) =>
} }
}; };
const coerceBoolean = (value: string | boolean | undefined) => {
if (typeof value === 'boolean') return value;
if (!value) return false;
return value.toString().toLowerCase() === 'true';
};
const coerceNumber = (value: string | number | undefined) => {
if (typeof value === 'number') return value;
const parsed = Number(value ?? '');
return Number.isNaN(parsed) ? 0 : parsed;
};
export const importFiles = async ( export const importFiles = async (
db: StockFillDB, db: StockFillDB,
files: File[], files: File[],
@@ -297,7 +263,7 @@ for (const file of parsedFiles) {
if (!selectedTypes.includes('products')) selectedTypes.push('products'); if (!selectedTypes.includes('products')) selectedTypes.push('products');
} else { } else {
// Fallback to filename-based inference for older templates // Fallback to filename-based inference for older templates
const type = inferTypeFromName(file.name); const type = inferTypeFromName(file.name) as DataType | undefined;
if (!type) { if (!type) {
addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`); addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`);
continue; continue;
+72
View File
@@ -0,0 +1,72 @@
export class MockTable<T extends { id: string }> {
items: T[];
constructor(items: T[] = []) {
this.items = items.slice();
}
async toArray() {
return [...this.items];
}
async add(item: T) {
this.items.push(item);
return item.id;
}
async get(id: string) {
return this.items.find((i) => i.id === id);
}
async put(item: T) {
const idx = this.items.findIndex((i) => i.id === item.id);
if (idx >= 0) {
this.items[idx] = item;
} else {
this.items.push(item);
}
return item.id;
}
async delete(id: string) {
this.items = this.items.filter((i) => i.id !== id);
}
where(field: string) {
return {
equals: (val: any) => ({
first: async () => this.items.find((it: any) => it[field] === val),
count: async () => this.items.filter((it: any) => it[field] === val).length,
filter: (pred: (it: any) => boolean) => ({
first: async () => this.items.find((it: any) => it[field] === val && pred(it)),
}),
}),
};
}
filter(pred: (it: any) => boolean) {
const filtered = this.items.filter(pred);
return {
delete: async () => {
this.items = this.items.filter((it) => !pred(it));
},
first: async () => filtered[0],
};
}
}
export const createMockDb = (data?: any) => {
return {
products: new MockTable(data?.products ?? []),
categories: new MockTable(data?.categories ?? []),
areas: new MockTable(data?.areas ?? []),
pickLists: new MockTable(data?.pickLists ?? []),
pickItems: new MockTable(data?.pickItems ?? []),
importExportLogs: new MockTable<any>([]),
transaction: async (_mode: string, ...args: any[]) => {
const cb = args[args.length - 1];
if (typeof cb === 'function') return cb();
return undefined;
},
};
};
+8
View File
@@ -0,0 +1,8 @@
export const stubDownloads = (vi: any) => {
const anchor = { href: '', download: '', click: vi.fn() } as any;
const createObjectURL = vi.fn(() => 'blob:url');
const revokeObjectURL = vi.fn();
vi.stubGlobal('document', { createElement: () => anchor });
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
return { anchor, createObjectURL, revokeObjectURL };
};
+11
View File
@@ -0,0 +1,11 @@
export const coerceBoolean = (value: string | boolean | undefined) => {
if (typeof value === 'boolean') return value;
if (!value) return false;
return value.toString().toLowerCase() === 'true';
};
export const coerceNumber = (value: string | number | undefined) => {
if (typeof value === 'number') return value;
const parsed = Number(value ?? '');
return Number.isNaN(parsed) ? 0 : parsed;
};
+19
View File
@@ -0,0 +1,19 @@
export const normalizeName = (value?: string) =>
(value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
export const typeHints: Record<string, string[]> = {
areas: ['area'],
categories: ['category'],
products: ['product'],
'pick-lists': ['picklist', 'pick-list'],
'pick-items': ['pickitem', 'pick-item'],
};
export const inferTypeFromName = (
name: string,
): (keyof typeof typeHints) | undefined => {
const lowercase = name.toLowerCase();
return (Object.keys(typeHints) as Array<keyof typeof typeHints>).find((key) =>
typeHints[key].some((hint) => lowercase.includes(hint)),
);
};