From ff0b2959b54ec74d091f89504568362d3d41c1e5 Mon Sep 17 00:00:00 2001 From: beatz174-bit Date: Mon, 1 Dec 2025 16:07:31 +1000 Subject: [PATCH] Add utilities and initial test coverage --- TESTING.md | 18 ++ package.json | 1 + src/platform/web.ts | 15 ++ src/screens/ActivePickListScreen.tsx | 3 +- src/screens/ManageProductsScreen.tsx | 3 +- ...ManageProductsScreen.addAndUpdate.test.tsx | 151 ++++++++++++++++ .../importExportService.export.single.test.ts | 33 ++++ ...ortExportService.import.errorCases.test.ts | 34 ++++ .../importExportService.utils.test.ts | 30 ++++ src/services/importExportService.test.ts | 170 ++++++------------ src/services/importExportService.ts | 54 ++---- src/testUtils/mockDb.ts | 72 ++++++++ src/testUtils/stubDownloads.ts | 8 + src/utils/convUtils.ts | 11 ++ src/utils/stringUtils.ts | 19 ++ 15 files changed, 462 insertions(+), 160 deletions(-) create mode 100644 TESTING.md create mode 100644 src/platform/web.ts create mode 100644 src/screens/__tests__/ManageProductsScreen.addAndUpdate.test.tsx create mode 100644 src/services/__tests__/importExportService.export.single.test.ts create mode 100644 src/services/__tests__/importExportService.import.errorCases.test.ts create mode 100644 src/services/__tests__/importExportService.utils.test.ts create mode 100644 src/testUtils/mockDb.ts create mode 100644 src/testUtils/stubDownloads.ts create mode 100644 src/utils/convUtils.ts create mode 100644 src/utils/stringUtils.ts 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)), + ); +};