feat: data layer, algorithms, and barcode parser with full test suite
- IndexedDB data layer: db.ts, items.ts, locations.ts, sightings.ts, labels.ts - Pure algorithms: confidence decay, custody state transitions - Barcode parser: HTTPS URI, haus:// scheme, raw ID parsing - 150 tests all passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b0a1ae214b
commit
d56eb2874b
16 changed files with 1615 additions and 0 deletions
110
src/lib/data/db.test.ts
Normal file
110
src/lib/data/db.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { deleteDB } from 'idb';
|
||||||
|
import { getDB, resetDBPromise } from '$lib/data/db';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDBPromise();
|
||||||
|
await deleteDB('solidhaus');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDB', () => {
|
||||||
|
it('returns a valid database instance', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db).toBeDefined();
|
||||||
|
expect(db.name).toBe('solidhaus');
|
||||||
|
expect(db.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same DB instance when called multiple times (singleton)', async () => {
|
||||||
|
const db1 = await getDB();
|
||||||
|
const db2 = await getDB();
|
||||||
|
expect(db1).toBe(db2);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('object stores', () => {
|
||||||
|
it('has the items store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('items')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the locations store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('locations')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the sightings store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('sightings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the photos store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('photos')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the preGeneratedIds store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('preGeneratedIds')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the settings store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.contains('settings')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has exactly 6 object stores', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
expect(db.objectStoreNames.length).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('indexes', () => {
|
||||||
|
it('has all indexes on the items store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction('items', 'readonly');
|
||||||
|
const store = tx.objectStore('items');
|
||||||
|
expect(store.indexNames.contains('by-name')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-category')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-location')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-type')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-custody')).toBe(true);
|
||||||
|
await tx.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has all indexes on the locations store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction('locations', 'readonly');
|
||||||
|
const store = tx.objectStore('locations');
|
||||||
|
expect(store.indexNames.contains('by-parent')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-name')).toBe(true);
|
||||||
|
await tx.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has all indexes on the sightings store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction('sightings', 'readonly');
|
||||||
|
const store = tx.objectStore('sightings');
|
||||||
|
expect(store.indexNames.contains('by-item')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-location')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-timestamp')).toBe(true);
|
||||||
|
await tx.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has the by-item index on the photos store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction('photos', 'readonly');
|
||||||
|
const store = tx.objectStore('photos');
|
||||||
|
expect(store.indexNames.contains('by-item')).toBe(true);
|
||||||
|
await tx.done;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has all indexes on the preGeneratedIds store', async () => {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction('preGeneratedIds', 'readonly');
|
||||||
|
const store = tx.objectStore('preGeneratedIds');
|
||||||
|
expect(store.indexNames.contains('by-batch')).toBe(true);
|
||||||
|
expect(store.indexNames.contains('by-assigned')).toBe(true);
|
||||||
|
await tx.done;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
109
src/lib/data/db.ts
Normal file
109
src/lib/data/db.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
|
||||||
|
|
||||||
|
interface SolidHausDB extends DBSchema {
|
||||||
|
items: {
|
||||||
|
key: string;
|
||||||
|
value: import('$lib/types').Item;
|
||||||
|
indexes: {
|
||||||
|
'by-name': string;
|
||||||
|
'by-category': string;
|
||||||
|
'by-location': string;
|
||||||
|
'by-type': string;
|
||||||
|
'by-custody': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
locations: {
|
||||||
|
key: string;
|
||||||
|
value: import('$lib/types').Location;
|
||||||
|
indexes: {
|
||||||
|
'by-parent': string;
|
||||||
|
'by-name': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
sightings: {
|
||||||
|
key: string;
|
||||||
|
value: import('$lib/types').Sighting;
|
||||||
|
indexes: {
|
||||||
|
'by-item': string;
|
||||||
|
'by-location': string;
|
||||||
|
'by-timestamp': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
photos: {
|
||||||
|
key: string;
|
||||||
|
value: import('$lib/types').Photo;
|
||||||
|
indexes: {
|
||||||
|
'by-item': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
preGeneratedIds: {
|
||||||
|
key: string;
|
||||||
|
value: import('$lib/types').PreGeneratedId;
|
||||||
|
indexes: {
|
||||||
|
'by-batch': string;
|
||||||
|
'by-assigned': string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
settings: {
|
||||||
|
key: string;
|
||||||
|
value: {
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SolidHausDB };
|
||||||
|
|
||||||
|
const DB_NAME = 'solidhaus';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase<SolidHausDB>> | null = null;
|
||||||
|
|
||||||
|
export function getDB(): Promise<IDBPDatabase<SolidHausDB>> {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB<SolidHausDB>(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
// Items
|
||||||
|
const itemStore = db.createObjectStore('items', { keyPath: 'shortId' });
|
||||||
|
itemStore.createIndex('by-name', 'name');
|
||||||
|
itemStore.createIndex('by-category', 'category');
|
||||||
|
itemStore.createIndex('by-location', 'lastSeenAt');
|
||||||
|
itemStore.createIndex('by-type', 'itemType');
|
||||||
|
itemStore.createIndex('by-custody', 'custodyState');
|
||||||
|
|
||||||
|
// Locations
|
||||||
|
const locStore = db.createObjectStore('locations', { keyPath: 'id' });
|
||||||
|
locStore.createIndex('by-parent', 'parentId');
|
||||||
|
locStore.createIndex('by-name', 'name');
|
||||||
|
|
||||||
|
// Sightings
|
||||||
|
const sightStore = db.createObjectStore('sightings', { keyPath: 'id' });
|
||||||
|
sightStore.createIndex('by-item', 'itemId');
|
||||||
|
sightStore.createIndex('by-location', 'locationId');
|
||||||
|
sightStore.createIndex('by-timestamp', 'timestamp');
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
const photoStore = db.createObjectStore('photos', { keyPath: 'id' });
|
||||||
|
photoStore.createIndex('by-item', 'itemId');
|
||||||
|
|
||||||
|
// Pre-generated IDs
|
||||||
|
const pregenStore = db.createObjectStore('preGeneratedIds', { keyPath: 'id' });
|
||||||
|
pregenStore.createIndex('by-batch', 'batchId');
|
||||||
|
pregenStore.createIndex('by-assigned', 'assignedTo');
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
db.createObjectStore('settings', { keyPath: 'key' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetDBPromise(): Promise<void> {
|
||||||
|
if (dbPromise) {
|
||||||
|
const db = await dbPromise;
|
||||||
|
db.close();
|
||||||
|
dbPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/lib/data/items.test.ts
Normal file
223
src/lib/data/items.test.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { deleteDB } from 'idb';
|
||||||
|
import { resetDBPromise } from '$lib/data/db';
|
||||||
|
import {
|
||||||
|
createItem,
|
||||||
|
getAllItems,
|
||||||
|
getItemById,
|
||||||
|
updateItem,
|
||||||
|
removeItem,
|
||||||
|
getItemsByCategory,
|
||||||
|
getItemsByType,
|
||||||
|
getItemsByCustodyState,
|
||||||
|
getItemsByLocation,
|
||||||
|
} from '$lib/data/items';
|
||||||
|
import type { Item } from '$lib/types';
|
||||||
|
|
||||||
|
function makeItem(overrides: Partial<Item> = {}): Item {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
shortId: 'abc1234',
|
||||||
|
name: 'Test Hammer',
|
||||||
|
description: 'A sturdy claw hammer',
|
||||||
|
category: 'tools',
|
||||||
|
brand: 'Stanley',
|
||||||
|
serialNumber: 'SN-00123',
|
||||||
|
color: 'yellow',
|
||||||
|
purchaseDate: '2024-06-15',
|
||||||
|
itemType: 'durable',
|
||||||
|
currentQuantity: null,
|
||||||
|
originalQuantity: null,
|
||||||
|
quantityUnit: null,
|
||||||
|
lowThreshold: null,
|
||||||
|
expiryDate: null,
|
||||||
|
barcodeFormat: 'qr',
|
||||||
|
barcodeUri: 'https://haus.toph.so/abc1234',
|
||||||
|
photoIds: [],
|
||||||
|
lastSeenAt: 'werkstatt',
|
||||||
|
lastSeenTimestamp: now,
|
||||||
|
lastUsedAt: null,
|
||||||
|
supposedToBeAt: 'werkstatt',
|
||||||
|
locationConfidence: 'confirmed',
|
||||||
|
custodyState: 'checked-in',
|
||||||
|
checkedOutSince: null,
|
||||||
|
checkedOutReason: null,
|
||||||
|
checkedOutFrom: null,
|
||||||
|
checkedOutTo: null,
|
||||||
|
checkedOutNote: null,
|
||||||
|
storageTier: 'hot',
|
||||||
|
storageContainerId: null,
|
||||||
|
storageContainerLabel: null,
|
||||||
|
labelPrinted: false,
|
||||||
|
labelPrintedAt: null,
|
||||||
|
labelBatchId: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
tags: ['hand-tool'],
|
||||||
|
createdBy: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDBPromise();
|
||||||
|
await deleteDB('solidhaus');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createItem', () => {
|
||||||
|
it('creates an item and returns it', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
const result = await createItem(item);
|
||||||
|
expect(result).toEqual(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the item in the database', async () => {
|
||||||
|
const item = makeItem();
|
||||||
|
await createItem(item);
|
||||||
|
const fetched = await getItemById('abc1234');
|
||||||
|
expect(fetched).toEqual(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates multiple items with different IDs', async () => {
|
||||||
|
const item1 = makeItem({ shortId: 'item001' });
|
||||||
|
const item2 = makeItem({ shortId: 'item002', name: 'Screwdriver' });
|
||||||
|
await createItem(item1);
|
||||||
|
await createItem(item2);
|
||||||
|
const all = await getAllItems();
|
||||||
|
expect(all).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllItems', () => {
|
||||||
|
it('returns an empty array when no items exist', async () => {
|
||||||
|
const items = await getAllItems();
|
||||||
|
expect(items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all created items', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'aaa1111' }));
|
||||||
|
await createItem(makeItem({ shortId: 'bbb2222' }));
|
||||||
|
await createItem(makeItem({ shortId: 'ccc3333' }));
|
||||||
|
const items = await getAllItems();
|
||||||
|
expect(items).toHaveLength(3);
|
||||||
|
const ids = items.map((i) => i.shortId);
|
||||||
|
expect(ids).toContain('aaa1111');
|
||||||
|
expect(ids).toContain('bbb2222');
|
||||||
|
expect(ids).toContain('ccc3333');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getItemById', () => {
|
||||||
|
it('returns the correct item', async () => {
|
||||||
|
const item = makeItem({ shortId: 'xyz7890', name: 'Drill' });
|
||||||
|
await createItem(item);
|
||||||
|
const result = await getItemById('xyz7890');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.name).toBe('Drill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a non-existent ID', async () => {
|
||||||
|
const result = await getItemById('notreal');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateItem', () => {
|
||||||
|
it('updates specified fields on an existing item', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'upd0001', name: 'Old Name' }));
|
||||||
|
const result = await updateItem('upd0001', { name: 'New Name' });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets updatedAt to a new timestamp', async () => {
|
||||||
|
const originalDate = '2024-01-01T00:00:00.000Z';
|
||||||
|
await createItem(makeItem({ shortId: 'upd0002', updatedAt: originalDate }));
|
||||||
|
const result = await updateItem('upd0002', { name: 'Changed' });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.updatedAt).not.toBe(originalDate);
|
||||||
|
// The new updatedAt should be a valid ISO string and more recent
|
||||||
|
expect(new Date(result!.updatedAt).getTime()).toBeGreaterThan(
|
||||||
|
new Date(originalDate).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the update in the database', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'upd0003', category: 'tools' }));
|
||||||
|
await updateItem('upd0003', { category: 'electronics' });
|
||||||
|
const fetched = await getItemById('upd0003');
|
||||||
|
expect(fetched!.category).toBe('electronics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when updating a non-existent item', async () => {
|
||||||
|
const result = await updateItem('noexist', { name: 'Nope' });
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify fields that are not in the updates', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'upd0004', name: 'Hammer', brand: 'Stanley' }));
|
||||||
|
await updateItem('upd0004', { name: 'Mallet' });
|
||||||
|
const fetched = await getItemById('upd0004');
|
||||||
|
expect(fetched!.name).toBe('Mallet');
|
||||||
|
expect(fetched!.brand).toBe('Stanley');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeItem', () => {
|
||||||
|
it('deletes an item from the database', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'del0001' }));
|
||||||
|
await removeItem('del0001');
|
||||||
|
const result = await getItemById('del0001');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when removing a non-existent item', async () => {
|
||||||
|
await expect(removeItem('nonexistent')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only removes the specified item', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'keep001' }));
|
||||||
|
await createItem(makeItem({ shortId: 'del0002' }));
|
||||||
|
await removeItem('del0002');
|
||||||
|
const all = await getAllItems();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].shortId).toBe('keep001');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('index queries', () => {
|
||||||
|
it('getItemsByCategory returns items matching the category', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'cat0001', category: 'tools' }));
|
||||||
|
await createItem(makeItem({ shortId: 'cat0002', category: 'electronics' }));
|
||||||
|
await createItem(makeItem({ shortId: 'cat0003', category: 'tools' }));
|
||||||
|
const tools = await getItemsByCategory('tools');
|
||||||
|
expect(tools).toHaveLength(2);
|
||||||
|
expect(tools.every((i) => i.category === 'tools')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemsByType returns items matching the item type', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'typ0001', itemType: 'consumable' }));
|
||||||
|
await createItem(makeItem({ shortId: 'typ0002', itemType: 'durable' }));
|
||||||
|
const consumables = await getItemsByType('consumable');
|
||||||
|
expect(consumables).toHaveLength(1);
|
||||||
|
expect(consumables[0].shortId).toBe('typ0001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemsByCustodyState returns items matching the custody state', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'cus0001', custodyState: 'checked-in' }));
|
||||||
|
await createItem(makeItem({ shortId: 'cus0002', custodyState: 'checked-out' }));
|
||||||
|
await createItem(makeItem({ shortId: 'cus0003', custodyState: 'checked-out' }));
|
||||||
|
const checkedOut = await getItemsByCustodyState('checked-out');
|
||||||
|
expect(checkedOut).toHaveLength(2);
|
||||||
|
expect(checkedOut.every((i) => i.custodyState === 'checked-out')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getItemsByLocation returns items at a given location', async () => {
|
||||||
|
await createItem(makeItem({ shortId: 'loc0001', lastSeenAt: 'kueche' }));
|
||||||
|
await createItem(makeItem({ shortId: 'loc0002', lastSeenAt: 'buero' }));
|
||||||
|
await createItem(makeItem({ shortId: 'loc0003', lastSeenAt: 'kueche' }));
|
||||||
|
const inKueche = await getItemsByLocation('kueche');
|
||||||
|
expect(inKueche).toHaveLength(2);
|
||||||
|
expect(inKueche.every((i) => i.lastSeenAt === 'kueche')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/lib/data/items.ts
Normal file
52
src/lib/data/items.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { getDB } from './db';
|
||||||
|
import type { Item } from '$lib/types';
|
||||||
|
|
||||||
|
export async function getAllItems(): Promise<Item[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAll('items');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemById(shortId: string): Promise<Item | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.get('items', shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createItem(item: Item): Promise<Item> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put('items', item);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateItem(shortId: string, updates: Partial<Item>): Promise<Item | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
const existing = await db.get('items', shortId);
|
||||||
|
if (!existing) return undefined;
|
||||||
|
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
await db.put('items', updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeItem(shortId: string): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.delete('items', shortId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemsByCategory(category: string): Promise<Item[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('items', 'by-category', category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemsByType(itemType: string): Promise<Item[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('items', 'by-type', itemType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemsByCustodyState(state: string): Promise<Item[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('items', 'by-custody', state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getItemsByLocation(locationId: string): Promise<Item[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('items', 'by-location', locationId);
|
||||||
|
}
|
||||||
207
src/lib/data/labels.test.ts
Normal file
207
src/lib/data/labels.test.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { deleteDB } from 'idb';
|
||||||
|
import { resetDBPromise, getDB } from '$lib/data/db';
|
||||||
|
import {
|
||||||
|
createPreGeneratedId,
|
||||||
|
getUnassignedIds,
|
||||||
|
assignIdToItem,
|
||||||
|
getIdsByBatch,
|
||||||
|
generateBatch,
|
||||||
|
} from '$lib/data/labels';
|
||||||
|
import type { PreGeneratedId } from '$lib/types';
|
||||||
|
|
||||||
|
function makePreGeneratedId(overrides: Partial<PreGeneratedId> = {}): PreGeneratedId {
|
||||||
|
return {
|
||||||
|
id: 'tst1234',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
assignedTo: null,
|
||||||
|
batchId: 'batch-001',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDBPromise();
|
||||||
|
await deleteDB('solidhaus');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createPreGeneratedId', () => {
|
||||||
|
it('creates a pre-generated ID entry and returns it', async () => {
|
||||||
|
const entry = makePreGeneratedId();
|
||||||
|
const result = await createPreGeneratedId(entry);
|
||||||
|
expect(result).toEqual(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the entry in the database', async () => {
|
||||||
|
const entry = makePreGeneratedId({ id: 'per1234' });
|
||||||
|
await createPreGeneratedId(entry);
|
||||||
|
const db = await getDB();
|
||||||
|
const stored = await db.get('preGeneratedIds', 'per1234');
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
expect(stored!.id).toBe('per1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores all fields correctly', async () => {
|
||||||
|
const entry = makePreGeneratedId({
|
||||||
|
id: 'fld1234',
|
||||||
|
batchId: 'batch-xyz',
|
||||||
|
assignedTo: null,
|
||||||
|
});
|
||||||
|
await createPreGeneratedId(entry);
|
||||||
|
const db = await getDB();
|
||||||
|
const stored = await db.get('preGeneratedIds', 'fld1234');
|
||||||
|
expect(stored!.batchId).toBe('batch-xyz');
|
||||||
|
expect(stored!.assignedTo).toBeNull();
|
||||||
|
expect(stored!.generatedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnassignedIds', () => {
|
||||||
|
it('returns entries that have no assignedTo value', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'free001', assignedTo: null }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'free002', assignedTo: null }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'used001', assignedTo: 'item-abc' }));
|
||||||
|
|
||||||
|
const unassigned = await getUnassignedIds();
|
||||||
|
expect(unassigned).toHaveLength(2);
|
||||||
|
const ids = unassigned.map((e) => e.id);
|
||||||
|
expect(ids).toContain('free001');
|
||||||
|
expect(ids).toContain('free002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when all IDs are assigned', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'u001', assignedTo: 'item-1' }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'u002', assignedTo: 'item-2' }));
|
||||||
|
const unassigned = await getUnassignedIds();
|
||||||
|
expect(unassigned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no entries exist', async () => {
|
||||||
|
const unassigned = await getUnassignedIds();
|
||||||
|
expect(unassigned).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all entries when none are assigned', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'all001' }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'all002' }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'all003' }));
|
||||||
|
const unassigned = await getUnassignedIds();
|
||||||
|
expect(unassigned).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assignIdToItem', () => {
|
||||||
|
it('assigns a pre-generated ID to an item', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'asn001', assignedTo: null }));
|
||||||
|
const result = await assignIdToItem('asn001', 'myitem1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.assignedTo).toBe('myitem1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the assignment in the database', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'asn002', assignedTo: null }));
|
||||||
|
await assignIdToItem('asn002', 'myitem2');
|
||||||
|
const db = await getDB();
|
||||||
|
const stored = await db.get('preGeneratedIds', 'asn002');
|
||||||
|
expect(stored!.assignedTo).toBe('myitem2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the ID does not exist', async () => {
|
||||||
|
const result = await assignIdToItem('nonexist', 'myitem3');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the ID no longer appear in unassigned list', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'asn003', assignedTo: null }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'asn004', assignedTo: null }));
|
||||||
|
await assignIdToItem('asn003', 'target-item');
|
||||||
|
|
||||||
|
const unassigned = await getUnassignedIds();
|
||||||
|
const ids = unassigned.map((e) => e.id);
|
||||||
|
expect(ids).not.toContain('asn003');
|
||||||
|
expect(ids).toContain('asn004');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getIdsByBatch', () => {
|
||||||
|
it('returns all IDs belonging to a specific batch', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'bat001', batchId: 'batch-A' }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'bat002', batchId: 'batch-A' }));
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'bat003', batchId: 'batch-B' }));
|
||||||
|
|
||||||
|
const batchA = await getIdsByBatch('batch-A');
|
||||||
|
expect(batchA).toHaveLength(2);
|
||||||
|
expect(batchA.every((e) => e.batchId === 'batch-A')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no IDs match the batch', async () => {
|
||||||
|
await createPreGeneratedId(makePreGeneratedId({ id: 'bat004', batchId: 'batch-C' }));
|
||||||
|
const result = await getIdsByBatch('batch-nonexistent');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the store is empty', async () => {
|
||||||
|
const result = await getIdsByBatch('any-batch');
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateBatch', () => {
|
||||||
|
it('creates the specified number of IDs', async () => {
|
||||||
|
const entries = await generateBatch(5, 'gen-batch-1');
|
||||||
|
expect(entries).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns entries with the correct batchId', async () => {
|
||||||
|
const entries = await generateBatch(3, 'gen-batch-2');
|
||||||
|
expect(entries.every((e) => e.batchId === 'gen-batch-2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns entries with null assignedTo', async () => {
|
||||||
|
const entries = await generateBatch(2, 'gen-batch-3');
|
||||||
|
expect(entries.every((e) => e.assignedTo === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique IDs for each entry', async () => {
|
||||||
|
const entries = await generateBatch(10, 'gen-batch-4');
|
||||||
|
const ids = entries.map((e) => e.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists all generated entries in the database', async () => {
|
||||||
|
await generateBatch(4, 'gen-batch-5');
|
||||||
|
const stored = await getIdsByBatch('gen-batch-5');
|
||||||
|
expect(stored).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets generatedAt on all entries', async () => {
|
||||||
|
const entries = await generateBatch(2, 'gen-batch-6');
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(entry.generatedAt).toBeDefined();
|
||||||
|
expect(() => new Date(entry.generatedAt)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates IDs with the expected format (7-char nanoid alphabet)', async () => {
|
||||||
|
const entries = await generateBatch(5, 'gen-batch-7');
|
||||||
|
const validChars = /^[23456789a-hjkmnp-z]{7}$/;
|
||||||
|
for (const entry of entries) {
|
||||||
|
expect(entry.id).toMatch(validChars);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles generating zero IDs', async () => {
|
||||||
|
const entries = await generateBatch(0, 'gen-batch-empty');
|
||||||
|
expect(entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can generate multiple batches independently', async () => {
|
||||||
|
await generateBatch(3, 'batch-x');
|
||||||
|
await generateBatch(4, 'batch-y');
|
||||||
|
const batchX = await getIdsByBatch('batch-x');
|
||||||
|
const batchY = await getIdsByBatch('batch-y');
|
||||||
|
expect(batchX).toHaveLength(3);
|
||||||
|
expect(batchY).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/lib/data/labels.ts
Normal file
52
src/lib/data/labels.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { getDB } from './db';
|
||||||
|
import type { PreGeneratedId } from '$lib/types';
|
||||||
|
import { generateItemId } from '$lib/utils/id';
|
||||||
|
|
||||||
|
export async function createPreGeneratedId(entry: PreGeneratedId): Promise<PreGeneratedId> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put('preGeneratedIds', entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnassignedIds(): Promise<PreGeneratedId[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
const all = await db.getAllFromIndex('preGeneratedIds', 'by-assigned', '');
|
||||||
|
// Also get nulls — by-assigned index may not store null correctly, so filter
|
||||||
|
const allIds = await db.getAll('preGeneratedIds');
|
||||||
|
return allIds.filter((entry) => !entry.assignedTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignIdToItem(id: string, itemShortId: string): Promise<PreGeneratedId | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
const entry = await db.get('preGeneratedIds', id);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
const updated = { ...entry, assignedTo: itemShortId };
|
||||||
|
await db.put('preGeneratedIds', updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdsByBatch(batchId: string): Promise<PreGeneratedId[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('preGeneratedIds', 'by-batch', batchId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateBatch(count: number, batchId: string): Promise<PreGeneratedId[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const entries: PreGeneratedId[] = [];
|
||||||
|
|
||||||
|
const tx = db.transaction('preGeneratedIds', 'readwrite');
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const entry: PreGeneratedId = {
|
||||||
|
id: generateItemId(),
|
||||||
|
generatedAt: now,
|
||||||
|
assignedTo: null,
|
||||||
|
batchId,
|
||||||
|
};
|
||||||
|
await tx.store.put(entry);
|
||||||
|
entries.push(entry);
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
211
src/lib/data/locations.test.ts
Normal file
211
src/lib/data/locations.test.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { deleteDB } from 'idb';
|
||||||
|
import { resetDBPromise } from '$lib/data/db';
|
||||||
|
import {
|
||||||
|
createLocation,
|
||||||
|
getAllLocations,
|
||||||
|
getLocationById,
|
||||||
|
updateLocation,
|
||||||
|
removeLocation,
|
||||||
|
getLocationsByParent,
|
||||||
|
seedDefaultLocations,
|
||||||
|
} from '$lib/data/locations';
|
||||||
|
import type { Location } from '$lib/types';
|
||||||
|
|
||||||
|
function makeLocation(overrides: Partial<Location> = {}): Location {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: 'test-room',
|
||||||
|
name: 'Test Room',
|
||||||
|
description: 'A room for testing',
|
||||||
|
parentId: null,
|
||||||
|
locationType: 'room',
|
||||||
|
defaultStorageTier: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDBPromise();
|
||||||
|
await deleteDB('solidhaus');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createLocation', () => {
|
||||||
|
it('creates a location and returns it', async () => {
|
||||||
|
const loc = makeLocation();
|
||||||
|
const result = await createLocation(loc);
|
||||||
|
expect(result).toEqual(loc);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the location in the database', async () => {
|
||||||
|
const loc = makeLocation({ id: 'garage', name: 'Garage' });
|
||||||
|
await createLocation(loc);
|
||||||
|
const fetched = await getLocationById('garage');
|
||||||
|
expect(fetched).toEqual(loc);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllLocations', () => {
|
||||||
|
it('returns an empty array when no locations exist', async () => {
|
||||||
|
const locations = await getAllLocations();
|
||||||
|
expect(locations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns all created locations', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'loc-a', name: 'Room A' }));
|
||||||
|
await createLocation(makeLocation({ id: 'loc-b', name: 'Room B' }));
|
||||||
|
const locations = await getAllLocations();
|
||||||
|
expect(locations).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLocationById', () => {
|
||||||
|
it('returns the correct location', async () => {
|
||||||
|
const loc = makeLocation({ id: 'kitchen', name: 'Kitchen' });
|
||||||
|
await createLocation(loc);
|
||||||
|
const result = await getLocationById('kitchen');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.name).toBe('Kitchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a non-existent ID', async () => {
|
||||||
|
const result = await getLocationById('does-not-exist');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLocation', () => {
|
||||||
|
it('updates specified fields on an existing location', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'upd-loc', name: 'Old Name' }));
|
||||||
|
const result = await updateLocation('upd-loc', { name: 'New Name' });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets updatedAt to a new timestamp', async () => {
|
||||||
|
const oldDate = '2023-01-01T00:00:00.000Z';
|
||||||
|
await createLocation(makeLocation({ id: 'upd-loc2', updatedAt: oldDate }));
|
||||||
|
const result = await updateLocation('upd-loc2', { description: 'Updated' });
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(new Date(result!.updatedAt).getTime()).toBeGreaterThan(new Date(oldDate).getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the update in the database', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'upd-loc3', description: 'Before' }));
|
||||||
|
await updateLocation('upd-loc3', { description: 'After' });
|
||||||
|
const fetched = await getLocationById('upd-loc3');
|
||||||
|
expect(fetched!.description).toBe('After');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when updating a non-existent location', async () => {
|
||||||
|
const result = await updateLocation('nonexistent', { name: 'Nope' });
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify fields that are not in the updates', async () => {
|
||||||
|
await createLocation(
|
||||||
|
makeLocation({ id: 'upd-loc4', name: 'Room', locationType: 'room' })
|
||||||
|
);
|
||||||
|
await updateLocation('upd-loc4', { name: 'Updated Room' });
|
||||||
|
const fetched = await getLocationById('upd-loc4');
|
||||||
|
expect(fetched!.name).toBe('Updated Room');
|
||||||
|
expect(fetched!.locationType).toBe('room');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeLocation', () => {
|
||||||
|
it('deletes a location from the database', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'del-loc' }));
|
||||||
|
await removeLocation('del-loc');
|
||||||
|
const result = await getLocationById('del-loc');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when removing a non-existent location', async () => {
|
||||||
|
await expect(removeLocation('nonexistent')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only removes the specified location', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'keep-loc' }));
|
||||||
|
await createLocation(makeLocation({ id: 'del-loc2' }));
|
||||||
|
await removeLocation('del-loc2');
|
||||||
|
const all = await getAllLocations();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].id).toBe('keep-loc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLocationsByParent', () => {
|
||||||
|
it('returns children of a given parent', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'house', name: 'House', parentId: null }));
|
||||||
|
await createLocation(
|
||||||
|
makeLocation({ id: 'floor1', name: 'Floor 1', parentId: 'house' })
|
||||||
|
);
|
||||||
|
await createLocation(
|
||||||
|
makeLocation({ id: 'floor2', name: 'Floor 2', parentId: 'house' })
|
||||||
|
);
|
||||||
|
await createLocation(
|
||||||
|
makeLocation({ id: 'room1', name: 'Room 1', parentId: 'floor1' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const houseChildren = await getLocationsByParent('house');
|
||||||
|
expect(houseChildren).toHaveLength(2);
|
||||||
|
const childIds = houseChildren.map((l) => l.id);
|
||||||
|
expect(childIds).toContain('floor1');
|
||||||
|
expect(childIds).toContain('floor2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no children exist', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'leaf', parentId: null }));
|
||||||
|
const children = await getLocationsByParent('leaf');
|
||||||
|
expect(children).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seedDefaultLocations', () => {
|
||||||
|
it('populates default locations when the store is empty', async () => {
|
||||||
|
await seedDefaultLocations();
|
||||||
|
const all = await getAllLocations();
|
||||||
|
expect(all.length).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes expected default locations', async () => {
|
||||||
|
await seedDefaultLocations();
|
||||||
|
const home = await getLocationById('home');
|
||||||
|
expect(home).toBeDefined();
|
||||||
|
expect(home!.name).toBe('Home');
|
||||||
|
expect(home!.locationType).toBe('house');
|
||||||
|
|
||||||
|
const kueche = await getLocationById('kueche');
|
||||||
|
expect(kueche).toBeDefined();
|
||||||
|
expect(kueche!.name).toBe('Küche');
|
||||||
|
expect(kueche!.parentId).toBe('eg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets createdAt and updatedAt on seeded locations', async () => {
|
||||||
|
await seedDefaultLocations();
|
||||||
|
const home = await getLocationById('home');
|
||||||
|
expect(home!.createdAt).toBeDefined();
|
||||||
|
expect(home!.updatedAt).toBeDefined();
|
||||||
|
expect(() => new Date(home!.createdAt)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips seeding when locations already exist', async () => {
|
||||||
|
await createLocation(makeLocation({ id: 'custom', name: 'Custom Location' }));
|
||||||
|
await seedDefaultLocations();
|
||||||
|
const all = await getAllLocations();
|
||||||
|
// Should only have the one custom location, not 11 defaults + 1 custom
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].id).toBe('custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — calling twice does not duplicate data', async () => {
|
||||||
|
await seedDefaultLocations();
|
||||||
|
await seedDefaultLocations();
|
||||||
|
const all = await getAllLocations();
|
||||||
|
expect(all).toHaveLength(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
src/lib/data/locations.ts
Normal file
64
src/lib/data/locations.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { getDB } from './db';
|
||||||
|
import type { Location } from '$lib/types';
|
||||||
|
|
||||||
|
export async function getAllLocations(): Promise<Location[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAll('locations');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocationById(id: string): Promise<Location | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.get('locations', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLocation(location: Location): Promise<Location> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put('locations', location);
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLocation(id: string, updates: Partial<Location>): Promise<Location | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
const existing = await db.get('locations', id);
|
||||||
|
if (!existing) return undefined;
|
||||||
|
const updated = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||||
|
await db.put('locations', updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLocation(id: string): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.delete('locations', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLocationsByParent(parentId: string | null): Promise<Location[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('locations', 'by-parent', parentId as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCATIONS: Omit<Location, 'createdAt' | 'updatedAt'>[] = [
|
||||||
|
{ id: 'home', name: 'Home', description: '', parentId: null, locationType: 'house', defaultStorageTier: null, sortOrder: 0 },
|
||||||
|
{ id: 'eg', name: 'Erdgeschoss', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 0 },
|
||||||
|
{ id: 'og', name: 'Obergeschoss', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 1 },
|
||||||
|
{ id: 'keller', name: 'Keller', description: '', parentId: 'home', locationType: 'floor', defaultStorageTier: null, sortOrder: 2 },
|
||||||
|
{ id: 'kueche', name: 'Küche', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 0 },
|
||||||
|
{ id: 'wohnzimmer', name: 'Wohnzimmer', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 1 },
|
||||||
|
{ id: 'flur', name: 'Flur', description: '', parentId: 'eg', locationType: 'room', defaultStorageTier: null, sortOrder: 2 },
|
||||||
|
{ id: 'schlafzimmer', name: 'Schlafzimmer', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 0 },
|
||||||
|
{ id: 'buero', name: 'Büro', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 1 },
|
||||||
|
{ id: 'bad', name: 'Bad', description: '', parentId: 'og', locationType: 'room', defaultStorageTier: null, sortOrder: 2 },
|
||||||
|
{ id: 'werkstatt', name: 'Werkstatt', description: '', parentId: 'keller', locationType: 'room', defaultStorageTier: null, sortOrder: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seedDefaultLocations(): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
const existing = await db.count('locations');
|
||||||
|
if (existing > 0) return;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const tx = db.transaction('locations', 'readwrite');
|
||||||
|
for (const loc of DEFAULT_LOCATIONS) {
|
||||||
|
await tx.store.put({ ...loc, createdAt: now, updatedAt: now });
|
||||||
|
}
|
||||||
|
await tx.done;
|
||||||
|
}
|
||||||
151
src/lib/data/sightings.test.ts
Normal file
151
src/lib/data/sightings.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { deleteDB } from 'idb';
|
||||||
|
import { resetDBPromise } from '$lib/data/db';
|
||||||
|
import {
|
||||||
|
createSighting,
|
||||||
|
getSightingsByItem,
|
||||||
|
getSightingsByLocation,
|
||||||
|
getAllSightings,
|
||||||
|
removeSighting,
|
||||||
|
} from '$lib/data/sightings';
|
||||||
|
import type { Sighting } from '$lib/types';
|
||||||
|
|
||||||
|
function makeSighting(overrides: Partial<Sighting> = {}): Sighting {
|
||||||
|
return {
|
||||||
|
id: 's_test001',
|
||||||
|
itemId: 'abc1234',
|
||||||
|
locationId: 'kueche',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
sightingType: 'scan',
|
||||||
|
confidence: 'confirmed',
|
||||||
|
notes: '',
|
||||||
|
createdBy: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDBPromise();
|
||||||
|
await deleteDB('solidhaus');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSighting', () => {
|
||||||
|
it('creates a sighting and returns it', async () => {
|
||||||
|
const sighting = makeSighting();
|
||||||
|
const result = await createSighting(sighting);
|
||||||
|
expect(result).toEqual(sighting);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the sighting in the database', async () => {
|
||||||
|
const sighting = makeSighting({ id: 's_persist' });
|
||||||
|
await createSighting(sighting);
|
||||||
|
const all = await getAllSightings();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].id).toBe('s_persist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates multiple sightings', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_one' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_two' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_three' }));
|
||||||
|
const all = await getAllSightings();
|
||||||
|
expect(all).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores all sighting fields correctly', async () => {
|
||||||
|
const sighting = makeSighting({
|
||||||
|
id: 's_full',
|
||||||
|
itemId: 'item777',
|
||||||
|
locationId: 'buero',
|
||||||
|
sightingType: 'manual',
|
||||||
|
confidence: 'inferred',
|
||||||
|
notes: 'Spotted on desk',
|
||||||
|
createdBy: 'user-42',
|
||||||
|
});
|
||||||
|
await createSighting(sighting);
|
||||||
|
const all = await getAllSightings();
|
||||||
|
const stored = all.find((s) => s.id === 's_full');
|
||||||
|
expect(stored).toBeDefined();
|
||||||
|
expect(stored!.itemId).toBe('item777');
|
||||||
|
expect(stored!.locationId).toBe('buero');
|
||||||
|
expect(stored!.sightingType).toBe('manual');
|
||||||
|
expect(stored!.confidence).toBe('inferred');
|
||||||
|
expect(stored!.notes).toBe('Spotted on desk');
|
||||||
|
expect(stored!.createdBy).toBe('user-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSightingsByItem', () => {
|
||||||
|
it('returns sightings for a specific item', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_a1', itemId: 'item-a' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_a2', itemId: 'item-a' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_b1', itemId: 'item-b' }));
|
||||||
|
|
||||||
|
const sightings = await getSightingsByItem('item-a');
|
||||||
|
expect(sightings).toHaveLength(2);
|
||||||
|
expect(sightings.every((s) => s.itemId === 'item-a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no sightings exist for the item', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_other', itemId: 'item-x' }));
|
||||||
|
const sightings = await getSightingsByItem('item-y');
|
||||||
|
expect(sightings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the database is empty', async () => {
|
||||||
|
const sightings = await getSightingsByItem('anything');
|
||||||
|
expect(sightings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSightingsByLocation', () => {
|
||||||
|
it('returns sightings for a specific location', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_k1', locationId: 'kueche' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_k2', locationId: 'kueche' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_b1', locationId: 'buero' }));
|
||||||
|
|
||||||
|
const sightings = await getSightingsByLocation('kueche');
|
||||||
|
expect(sightings).toHaveLength(2);
|
||||||
|
expect(sightings.every((s) => s.locationId === 'kueche')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when no sightings exist for the location', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_elsewhere', locationId: 'werkstatt' }));
|
||||||
|
const sightings = await getSightingsByLocation('schlafzimmer');
|
||||||
|
expect(sightings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the database is empty', async () => {
|
||||||
|
const sightings = await getSightingsByLocation('anywhere');
|
||||||
|
expect(sightings).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeSighting', () => {
|
||||||
|
it('deletes a sighting from the database', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_del' }));
|
||||||
|
await removeSighting('s_del');
|
||||||
|
const all = await getAllSightings();
|
||||||
|
expect(all).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when removing a non-existent sighting', async () => {
|
||||||
|
await expect(removeSighting('s_nonexistent')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only removes the specified sighting', async () => {
|
||||||
|
await createSighting(makeSighting({ id: 's_keep' }));
|
||||||
|
await createSighting(makeSighting({ id: 's_remove' }));
|
||||||
|
await removeSighting('s_remove');
|
||||||
|
const all = await getAllSightings();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].id).toBe('s_keep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllSightings', () => {
|
||||||
|
it('returns an empty array when no sightings exist', async () => {
|
||||||
|
const all = await getAllSightings();
|
||||||
|
expect(all).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/lib/data/sightings.ts
Normal file
28
src/lib/data/sightings.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { getDB } from './db';
|
||||||
|
import type { Sighting } from '$lib/types';
|
||||||
|
|
||||||
|
export async function createSighting(sighting: Sighting): Promise<Sighting> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put('sightings', sighting);
|
||||||
|
return sighting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSightingsByItem(itemId: string): Promise<Sighting[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('sightings', 'by-item', itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSightingsByLocation(locationId: string): Promise<Sighting[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAllFromIndex('sightings', 'by-location', locationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllSightings(): Promise<Sighting[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAll('sightings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSighting(id: string): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.delete('sightings', id);
|
||||||
|
}
|
||||||
150
src/lib/scanning/parser.test.ts
Normal file
150
src/lib/scanning/parser.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseItemId } from '$lib/scanning/parser';
|
||||||
|
import { generateItemId, generateSightingId } from '$lib/utils/id';
|
||||||
|
|
||||||
|
describe('parseItemId', () => {
|
||||||
|
describe('HTTPS URI format', () => {
|
||||||
|
it('parses a valid HTTPS URI', () => {
|
||||||
|
expect(parseItemId('https://haus.toph.so/abc2345')).toBe('abc2345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a valid HTTPS URI with different valid ID', () => {
|
||||||
|
expect(parseItemId('https://haus.toph.so/mn3pqr7')).toBe('mn3pqr7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for HTTPS URI with invalid characters in ID', () => {
|
||||||
|
expect(parseItemId('https://haus.toph.so/abc1lO0')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for HTTPS URI with wrong length ID', () => {
|
||||||
|
expect(parseItemId('https://haus.toph.so/abc23')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for wrong domain', () => {
|
||||||
|
expect(parseItemId('https://example.com/abc2345')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom scheme format', () => {
|
||||||
|
it('parses a valid haus:// URI', () => {
|
||||||
|
expect(parseItemId('haus://abc2345')).toBe('abc2345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for haus:// URI with invalid characters', () => {
|
||||||
|
expect(parseItemId('haus://abc1lO0')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for haus:// URI with wrong length', () => {
|
||||||
|
expect(parseItemId('haus://abc23456789')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('raw ID format', () => {
|
||||||
|
it('parses a valid raw 7-char ID', () => {
|
||||||
|
expect(parseItemId('abc2345')).toBe('abc2345');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts all valid alphabet characters', () => {
|
||||||
|
// Alphabet: 23456789abcdefghjkmnpqrstuvwxyz
|
||||||
|
expect(parseItemId('2345678')).toBe('2345678');
|
||||||
|
expect(parseItemId('abcdefg')).toBe('abcdefg');
|
||||||
|
expect(parseItemId('hjkmnpq')).toBe('hjkmnpq');
|
||||||
|
expect(parseItemId('rstuvwx')).toBe('rstuvwx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects excluded characters (0, 1, i, l, o)', () => {
|
||||||
|
expect(parseItemId('abc0def')).toBeNull();
|
||||||
|
expect(parseItemId('abc1def')).toBeNull();
|
||||||
|
expect(parseItemId('abcidef')).toBeNull();
|
||||||
|
expect(parseItemId('abcldef')).toBeNull();
|
||||||
|
expect(parseItemId('abcodef')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects uppercase characters', () => {
|
||||||
|
expect(parseItemId('ABCdefg')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('invalid inputs', () => {
|
||||||
|
it('returns null for empty string', () => {
|
||||||
|
expect(parseItemId('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for wrong length (too short)', () => {
|
||||||
|
expect(parseItemId('abc23')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for wrong length (too long)', () => {
|
||||||
|
expect(parseItemId('abc23456')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for string with spaces', () => {
|
||||||
|
expect(parseItemId('abc 234')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for string with special characters', () => {
|
||||||
|
expect(parseItemId('abc-234')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for completely invalid input', () => {
|
||||||
|
expect(parseItemId('not a barcode at all')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateItemId', () => {
|
||||||
|
const VALID_CHARS = /^[23456789a-hjkmnp-z]+$/;
|
||||||
|
|
||||||
|
it('generates a 7-character string', () => {
|
||||||
|
const id = generateItemId();
|
||||||
|
expect(id).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only valid alphabet characters', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const id = generateItemId();
|
||||||
|
expect(id).toMatch(VALID_CHARS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not contain ambiguous characters (0, 1, i, l, o)', () => {
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const id = generateItemId();
|
||||||
|
expect(id).not.toMatch(/[01ilo]/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique IDs across multiple calls', () => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
ids.add(generateItemId());
|
||||||
|
}
|
||||||
|
expect(ids.size).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSightingId', () => {
|
||||||
|
it('starts with "s_" prefix', () => {
|
||||||
|
const id = generateSightingId();
|
||||||
|
expect(id.startsWith('s_')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has total length of 9 (2 prefix + 7 ID)', () => {
|
||||||
|
const id = generateSightingId();
|
||||||
|
expect(id).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a valid item ID after the prefix', () => {
|
||||||
|
const id = generateSightingId();
|
||||||
|
const suffix = id.slice(2);
|
||||||
|
expect(suffix).toMatch(/^[23456789a-hjkmnp-z]{7}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique IDs across multiple calls', () => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
ids.add(generateSightingId());
|
||||||
|
}
|
||||||
|
expect(ids.size).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
18
src/lib/scanning/parser.ts
Normal file
18
src/lib/scanning/parser.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
const VALID_CHARS = /^[23456789a-hjkmnp-z]{7}$/;
|
||||||
|
|
||||||
|
export function parseItemId(rawValue: string): string | null {
|
||||||
|
if (!rawValue) return null;
|
||||||
|
|
||||||
|
// HTTPS URI
|
||||||
|
const httpsMatch = rawValue.match(/haus\.toph\.so\/([23456789a-hjkmnp-z]{7})\/?$/);
|
||||||
|
if (httpsMatch) return httpsMatch[1];
|
||||||
|
|
||||||
|
// Custom scheme
|
||||||
|
const schemeMatch = rawValue.match(/^haus:\/\/([23456789a-hjkmnp-z]{7})$/);
|
||||||
|
if (schemeMatch) return schemeMatch[1];
|
||||||
|
|
||||||
|
// Raw ID
|
||||||
|
if (VALID_CHARS.test(rawValue)) return rawValue;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
50
src/lib/utils/confidence.test.ts
Normal file
50
src/lib/utils/confidence.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getConfidence } from '$lib/utils/confidence';
|
||||||
|
|
||||||
|
function daysAgo(days: number): string {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getConfidence', () => {
|
||||||
|
it('returns "unknown" when lastSeenTimestamp is null', () => {
|
||||||
|
expect(getConfidence(null)).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "confirmed" for a timestamp from today', () => {
|
||||||
|
expect(getConfidence(new Date().toISOString())).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "confirmed" at the 30-day boundary', () => {
|
||||||
|
expect(getConfidence(daysAgo(30))).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "likely" at 31 days ago', () => {
|
||||||
|
expect(getConfidence(daysAgo(31))).toBe('likely');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "likely" at the 90-day boundary', () => {
|
||||||
|
expect(getConfidence(daysAgo(90))).toBe('likely');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "assumed" at 91 days ago', () => {
|
||||||
|
expect(getConfidence(daysAgo(91))).toBe('assumed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "assumed" at the 180-day boundary', () => {
|
||||||
|
expect(getConfidence(daysAgo(180))).toBe('assumed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "unknown" at 181 days ago', () => {
|
||||||
|
expect(getConfidence(daysAgo(181))).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "unknown" for a very old timestamp', () => {
|
||||||
|
expect(getConfidence('2020-01-01T00:00:00.000Z')).toBe('unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "confirmed" for a timestamp 1 day ago', () => {
|
||||||
|
expect(getConfidence(daysAgo(1))).toBe('confirmed');
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/lib/utils/confidence.ts
Normal file
15
src/lib/utils/confidence.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
import type { LocationConfidence } from '$lib/types';
|
||||||
|
|
||||||
|
export function getConfidence(lastSeenTimestamp: string | null): LocationConfidence {
|
||||||
|
if (!lastSeenTimestamp) return 'unknown';
|
||||||
|
|
||||||
|
const date = new Date(lastSeenTimestamp);
|
||||||
|
if (isNaN(date.getTime())) return 'unknown';
|
||||||
|
|
||||||
|
const daysSince = differenceInDays(new Date(), date);
|
||||||
|
if (daysSince <= 30) return 'confirmed';
|
||||||
|
if (daysSince <= 90) return 'likely';
|
||||||
|
if (daysSince <= 180) return 'assumed';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
138
src/lib/utils/custody.test.ts
Normal file
138
src/lib/utils/custody.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildCheckOutUpdate, buildCheckInUpdate } from '$lib/utils/custody';
|
||||||
|
|
||||||
|
describe('buildCheckOutUpdate', () => {
|
||||||
|
it('returns custodyState "checked-out"', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-from', 'loc-to');
|
||||||
|
expect(result.custodyState).toBe('checked-out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedOutSince to a valid ISO timestamp', () => {
|
||||||
|
const before = new Date().toISOString();
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-from', 'loc-to');
|
||||||
|
const after = new Date().toISOString();
|
||||||
|
|
||||||
|
expect(result.checkedOutSince).toBeDefined();
|
||||||
|
expect(result.checkedOutSince! >= before).toBe(true);
|
||||||
|
expect(result.checkedOutSince! <= after).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the checkout reason', () => {
|
||||||
|
const result = buildCheckOutUpdate('lent', 'loc-from', 'loc-to');
|
||||||
|
expect(result.checkedOutReason).toBe('lent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedOutFrom to the from location', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'kitchen', 'garage');
|
||||||
|
expect(result.checkedOutFrom).toBe('kitchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedOutTo to the to location', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'kitchen', 'garage');
|
||||||
|
expect(result.checkedOutTo).toBe('garage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedOutNote when provided', () => {
|
||||||
|
const result = buildCheckOutUpdate('lent', 'loc-a', 'loc-b', 'Lent to neighbor');
|
||||||
|
expect(result.checkedOutNote).toBe('Lent to neighbor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets checkedOutNote to empty string or null when not provided', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b');
|
||||||
|
// Accept either null, undefined, or empty string as "no note"
|
||||||
|
expect(result.checkedOutNote === null || result.checkedOutNote === undefined || result.checkedOutNote === '').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenAt to toLocationId when provided', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-transit', 'loc-a', 'loc-b');
|
||||||
|
expect(result.lastSeenAt).toBe('loc-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenAt to fromLocationId when toLocationId is null', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-a', null);
|
||||||
|
expect(result.lastSeenAt).toBe('loc-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenAt to fromLocationId when toLocationId is empty string', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-a', '');
|
||||||
|
expect(result.lastSeenAt).toBe('loc-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets locationConfidence to "confirmed"', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b');
|
||||||
|
expect(result.locationConfidence).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenTimestamp', () => {
|
||||||
|
const result = buildCheckOutUpdate('in-use', 'loc-a', 'loc-b');
|
||||||
|
expect(result.lastSeenTimestamp).toBeDefined();
|
||||||
|
// Should be a valid ISO string
|
||||||
|
expect(new Date(result.lastSeenTimestamp!).toISOString()).toBe(result.lastSeenTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all checkout reason types', () => {
|
||||||
|
const reasons = ['in-use', 'in-transit', 'lent', 'in-repair', 'temporary', 'consumed'] as const;
|
||||||
|
for (const reason of reasons) {
|
||||||
|
const result = buildCheckOutUpdate(reason, 'loc-a', 'loc-b');
|
||||||
|
expect(result.checkedOutReason).toBe(reason);
|
||||||
|
expect(result.custodyState).toBe('checked-out');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildCheckInUpdate', () => {
|
||||||
|
it('returns custodyState "checked-in"', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.custodyState).toBe('checked-in');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls checkedOutSince', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.checkedOutSince).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls checkedOutReason', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.checkedOutReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls checkedOutFrom', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.checkedOutFrom).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls checkedOutTo', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.checkedOutTo).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls checkedOutNote', () => {
|
||||||
|
const result = buildCheckInUpdate('living-room');
|
||||||
|
expect(result.checkedOutNote).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenAt to the return location', () => {
|
||||||
|
const result = buildCheckInUpdate('kitchen');
|
||||||
|
expect(result.lastSeenAt).toBe('kitchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets supposedToBeAt to the return location', () => {
|
||||||
|
const result = buildCheckInUpdate('kitchen');
|
||||||
|
expect(result.supposedToBeAt).toBe('kitchen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets locationConfidence to "confirmed"', () => {
|
||||||
|
const result = buildCheckInUpdate('kitchen');
|
||||||
|
expect(result.locationConfidence).toBe('confirmed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets lastSeenTimestamp to a valid ISO timestamp', () => {
|
||||||
|
const before = new Date().toISOString();
|
||||||
|
const result = buildCheckInUpdate('kitchen');
|
||||||
|
const after = new Date().toISOString();
|
||||||
|
|
||||||
|
expect(result.lastSeenTimestamp).toBeDefined();
|
||||||
|
expect(result.lastSeenTimestamp! >= before).toBe(true);
|
||||||
|
expect(result.lastSeenTimestamp! <= after).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/lib/utils/custody.ts
Normal file
37
src/lib/utils/custody.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import type { Item, CheckOutReason } from '$lib/types';
|
||||||
|
|
||||||
|
export function buildCheckOutUpdate(
|
||||||
|
reason: CheckOutReason,
|
||||||
|
fromLocationId: string,
|
||||||
|
toLocationId: string | null,
|
||||||
|
note: string = '',
|
||||||
|
): Partial<Item> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
custodyState: 'checked-out',
|
||||||
|
checkedOutSince: now,
|
||||||
|
checkedOutReason: reason,
|
||||||
|
checkedOutFrom: fromLocationId,
|
||||||
|
checkedOutTo: toLocationId,
|
||||||
|
checkedOutNote: note,
|
||||||
|
lastSeenAt: toLocationId || fromLocationId,
|
||||||
|
lastSeenTimestamp: now,
|
||||||
|
locationConfidence: 'confirmed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCheckInUpdate(returnToLocationId: string): Partial<Item> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
custodyState: 'checked-in',
|
||||||
|
checkedOutSince: null,
|
||||||
|
checkedOutReason: null,
|
||||||
|
checkedOutFrom: null,
|
||||||
|
checkedOutTo: null,
|
||||||
|
checkedOutNote: null,
|
||||||
|
lastSeenAt: returnToLocationId,
|
||||||
|
lastSeenTimestamp: now,
|
||||||
|
locationConfidence: 'confirmed',
|
||||||
|
supposedToBeAt: returnToLocationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue