diff --git a/TESTING.md b/TESTING.md
new file mode 100644
index 0000000..41e1449
--- /dev/null
+++ b/TESTING.md
@@ -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.
diff --git a/package.json b/package.json
index 366f84d..8c803cf 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest",
+ "test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
diff --git a/src/platform/web.ts b/src/platform/web.ts
new file mode 100644
index 0000000..ef67457
--- /dev/null
+++ b/src/platform/web.ts
@@ -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);
+};
diff --git a/src/screens/ActivePickListScreen.tsx b/src/screens/ActivePickListScreen.tsx
index a82cba4..fb4a87a 100644
--- a/src/screens/ActivePickListScreen.tsx
+++ b/src/screens/ActivePickListScreen.tsx
@@ -31,8 +31,7 @@ import { PickItemRow } from '../components/PickItemRow';
import { PickItem } from '../models/PickItem';
import { Product } from '../models/Product';
import { ProductAutocomplete } from '../components/ProductAutocomplete';
-
-const normalizeName = (name: string) => name.trim().toLowerCase();
+import { normalizeName } from '../utils/stringUtils';
const ActivePickListScreen = () => {
const { id } = useParams();
diff --git a/src/screens/ManageProductsScreen.tsx b/src/screens/ManageProductsScreen.tsx
index db11286..421dd65 100644
--- a/src/screens/ManageProductsScreen.tsx
+++ b/src/screens/ManageProductsScreen.tsx
@@ -24,6 +24,7 @@ import { useDatabase } from '../context/DBProvider';
import { BarcodeScannerView } from '../components/BarcodeScannerView';
import { ExternalProductInfo, fetchProductFromOFF } from '../modules/openFoodFacts';
import { DEFAULT_BULK_NAME, DEFAULT_UNIT_TYPE, Product } from '../models/Product';
+import { isOnline } from '../platform/web';
const ManageProductsScreen = () => {
const db = useDatabase();
@@ -184,7 +185,7 @@ const ManageProductsScreen = () => {
async function lookupBarcode(code: string) {
if (!code) return;
- if (typeof navigator !== 'undefined' && 'onLine' in navigator && !navigator.onLine) {
+ if (!isOnline()) {
setLookupStatus('offline');
setExternalProduct(null);
return;
diff --git a/src/screens/__tests__/ManageProductsScreen.addAndUpdate.test.tsx b/src/screens/__tests__/ManageProductsScreen.addAndUpdate.test.tsx
new file mode 100644
index 0000000..73dc3b1
--- /dev/null
+++ b/src/screens/__tests__/ManageProductsScreen.addAndUpdate.test.tsx
@@ -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 }) => (
+
+ ),
+}));
+
+const clickSaveButton = async (user: ReturnType) => {
+ 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, 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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);
+ });
+ });
+});
diff --git a/src/services/__tests__/importExportService.export.single.test.ts b/src/services/__tests__/importExportService.export.single.test.ts
new file mode 100644
index 0000000..cb39f6a
--- /dev/null
+++ b/src/services/__tests__/importExportService.export.single.test.ts
@@ -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);
+ });
+});
diff --git a/src/services/__tests__/importExportService.import.errorCases.test.ts b/src/services/__tests__/importExportService.import.errorCases.test.ts
new file mode 100644
index 0000000..0e9dedb
--- /dev/null
+++ b/src/services/__tests__/importExportService.import.errorCases.test.ts
@@ -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);
+ });
+});
diff --git a/src/services/__tests__/importExportService.utils.test.ts b/src/services/__tests__/importExportService.utils.test.ts
new file mode 100644
index 0000000..a9fd542
--- /dev/null
+++ b/src/services/__tests__/importExportService.utils.test.ts
@@ -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);
+ });
+});
diff --git a/src/services/importExportService.test.ts b/src/services/importExportService.test.ts
index d750541..f7229ab 100644
--- a/src/services/importExportService.test.ts
+++ b/src/services/importExportService.test.ts
@@ -1,63 +1,57 @@
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 { Product } from '../models/Product';
-import { Category } from '../models/Category';
-import { Area } from '../models/Area';
-import { PickList } from '../models/PickList';
-import { PickItem } from '../models/PickItem';
-import { StockFillDB } from '../db';
+import { createMockDb } from '../testUtils/mockDb';
+import { stubDownloads } from '../testUtils/stubDownloads';
-class MockTable {
- constructor(public items: T[] = []) {}
+const csvContent = [
+ '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() {
- return [...this.items];
- }
+const csv = { name: 'products.csv', text: async () => csvContent } as unknown as File;
- async add(item: T) {
- 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(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([]),
- transaction: async (_mode: string, ...args: any[]) => {
- const callback = args[args.length - 1];
- return callback();
+const baseDb = createMockDb({
+ products: [
+ {
+ id: 'p1',
+ name: 'Shirt',
+ category: 'c1',
+ unit_type: 'unit',
+ bulk_name: 'carton',
+ barcode: '123',
+ archived: false,
+ created_at: 1,
+ updated_at: 2,
},
- } 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 };
-};
+ ],
+ categories: [{ id: 'c1', name: 'Clothing', created_at: 1, updated_at: 2 }],
+ areas: [{ id: 'a1', name: 'Area A', created_at: 1, updated_at: 2 }],
+ pickLists: [
+ {
+ id: 'l1',
+ area_id: 'a1',
+ created_at: 1,
+ completed_at: undefined,
+ notes: 'Morning',
+ categories: ['c1'],
+ auto_add_new_products: false,
+ },
+ ],
+ pickItems: [
+ {
+ id: 'i1',
+ pick_list_id: 'l1',
+ product_id: 'p1',
+ quantity: 1,
+ is_carton: false,
+ status: 'pending',
+ created_at: 1,
+ updated_at: 2,
+ },
+ ],
+});
describe('import/export service', () => {
afterEach(() => {
@@ -66,52 +60,15 @@ describe('import/export service', () => {
it('exports multiple types into a zip with friendly values', async () => {
const db = createMockDb({
- products: [
- {
- id: 'p1',
- name: 'Shirt',
- category: 'c1',
- unit_type: 'unit',
- bulk_name: 'carton',
- barcode: '123',
- archived: false,
- created_at: 1,
- updated_at: 2,
- },
- ],
- categories: [
- { id: 'c1', name: 'Clothing', created_at: 1, updated_at: 2 },
- ],
- areas: [
- { id: 'a1', name: 'Area A', created_at: 1, updated_at: 2 },
- ],
- pickLists: [
- {
- id: 'l1',
- area_id: 'a1',
- created_at: 1,
- completed_at: undefined,
- notes: 'Morning',
- categories: ['c1'],
- auto_add_new_products: false,
- },
- ],
- pickItems: [
- {
- id: 'i1',
- pick_list_id: 'l1',
- product_id: 'p1',
- quantity: 1,
- is_carton: false,
- status: 'pending',
- created_at: 1,
- updated_at: 2,
- },
- ],
+ products: baseDb.products.items,
+ categories: baseDb.categories.items,
+ areas: baseDb.areas.items,
+ pickLists: baseDb.pickLists.items,
+ pickItems: baseDb.pickItems.items,
});
- stubDownloads();
+ stubDownloads(vi);
- const result = await exportData(db, ['products', 'categories', 'pick-lists', 'pick-items']);
+ const result = await exportData(db as any, ['products', 'categories', 'pick-lists', 'pick-items']);
const zip = await JSZip.loadAsync(result.blob as any);
const productCsv = await zip.file('products.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 }],
});
- stubDownloads();
+ stubDownloads(vi);
- const csvContent = [
- '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,
- );
+ const result = await importFiles(db as any, [csv], { allowAutoCreateMissing: true }, () => undefined);
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 === 'New Shirt')).toBeTruthy();
expect((db.categories as any).items.find((c: any) => c.name === 'New Category')).toBeTruthy();
-
});
});
diff --git a/src/services/importExportService.ts b/src/services/importExportService.ts
index 12fcb51..543fe37 100644
--- a/src/services/importExportService.ts
+++ b/src/services/importExportService.ts
@@ -8,6 +8,11 @@ import { Category } from '../models/Category';
import { PickItem, PickItemStatus } from '../models/PickItem';
import { PickList } from '../models/PickList';
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';
@@ -30,30 +35,12 @@ interface ParsedFile {
content: string;
}
-const typeHints: Record = {
- 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 result = Papa.parse>(content, {
- header: true,
- skipEmptyLines: true,
- transformHeader: (header: string) => header.trim().toLowerCase(),
-});
+ header: true,
+ skipEmptyLines: true,
+ transformHeader: (header: string) => header.trim().toLowerCase(),
+ });
if (result.errors.length > 0) {
@@ -101,15 +88,6 @@ const createLog = (
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 (
db: StockFillDB,
selectedTypes: DataType[],
@@ -250,18 +228,6 @@ const addOrUpdateMap = (map: Map, 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 (
db: StockFillDB,
files: File[],
@@ -297,7 +263,7 @@ for (const file of parsedFiles) {
if (!selectedTypes.includes('products')) selectedTypes.push('products');
} else {
// Fallback to filename-based inference for older templates
- const type = inferTypeFromName(file.name);
+ const type = inferTypeFromName(file.name) as DataType | undefined;
if (!type) {
addDetail(`Skipped ${file.name}: not product-centric and could not infer data type`);
continue;
diff --git a/src/testUtils/mockDb.ts b/src/testUtils/mockDb.ts
new file mode 100644
index 0000000..92cc0cb
--- /dev/null
+++ b/src/testUtils/mockDb.ts
@@ -0,0 +1,72 @@
+export class MockTable {
+ 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([]),
+ transaction: async (_mode: string, ...args: any[]) => {
+ const cb = args[args.length - 1];
+ if (typeof cb === 'function') return cb();
+ return undefined;
+ },
+ };
+};
diff --git a/src/testUtils/stubDownloads.ts b/src/testUtils/stubDownloads.ts
new file mode 100644
index 0000000..cd957e0
--- /dev/null
+++ b/src/testUtils/stubDownloads.ts
@@ -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 };
+};
diff --git a/src/utils/convUtils.ts b/src/utils/convUtils.ts
new file mode 100644
index 0000000..5cef7c5
--- /dev/null
+++ b/src/utils/convUtils.ts
@@ -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;
+};
diff --git a/src/utils/stringUtils.ts b/src/utils/stringUtils.ts
new file mode 100644
index 0000000..9bd5a48
--- /dev/null
+++ b/src/utils/stringUtils.ts
@@ -0,0 +1,19 @@
+export const normalizeName = (value?: string) =>
+ (value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
+
+export const typeHints: Record = {
+ 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).find((key) =>
+ typeHints[key].some((hint) => lowercase.includes(hint)),
+ );
+};