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:
+18
@@ -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.
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user