Add utilities and initial test coverage
This commit is contained in:
@@ -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 { 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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<T extends { id: string }> {
|
||||
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<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();
|
||||
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();
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 result = Papa.parse<Record<string, string>>(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<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 (
|
||||
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;
|
||||
|
||||
@@ -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